Compare commits

...
19 Commits
Author SHA1 Message Date
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
79 changed files with 3870 additions and 295 deletions

No files matched your search

+2 -1
View File
@@ -136,6 +136,7 @@ Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the
Administration (permission `fantasystick.admin`, default operators):
- `/fantasystick character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
@@ -166,7 +167,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 `/fantasyparticles players on`. The account-owned aura preference defaults to off; `/fantasyparticles players` reports it and `/fantasyparticles players off` hides only this recurring aura. The independent, default-on `/fantasyparticles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
`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.
@@ -55,6 +55,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private EquipmentAdminCommand admin;
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;
@@ -124,6 +126,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.adventure.AdventureQuestDirectory adventureQuests;
private games.dmg.fantasystick.adventure.AdventureQuestController adventureQuestController;
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
private games.dmg.fantasystick.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;
@@ -269,8 +274,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.
@@ -312,6 +321,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
adventureQuests = new games.dmg.fantasystick.adventure.AdventureQuestDirectory(
new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(getDataFolder().toPath()),
Clock.systemUTC(), adventureQuestSettings::rules, java.util.random.RandomGenerator.getDefault());
dungeons = new games.dmg.fantasystick.dungeon.DungeonDirectory(
new games.dmg.fantasystick.persistence.YamlDungeonRepository(getDataFolder().toPath()),
() -> adventureQuestSettings.rules().dungeonRoomLinkDistance());
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
} catch (java.io.IOException invalidManagedState) {
throw new java.io.UncheckedIOException("Persistent party, shop, training or adventure state could not be loaded", invalidManagedState);
@@ -350,15 +362,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);
@@ -408,21 +421,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,
@@ -502,7 +518,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);
@@ -540,10 +556,22 @@ public final class FantasyStickPlugin extends JavaPlugin {
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());
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault(), particleDelivery);
adventureQuestCommands = new games.dmg.fantasystick.adventure.AdventureQuestCommands(
this, adventureQuests, adventureQuestController, nextTick);
combatExperience.attachExcluded(adventureQuestController::managed);
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);
var custodyDirectory=dungeons;
sessions.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
characters.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
characterParties.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
dungeonCommands = new games.dmg.fantasystick.dungeon.DungeonCommands(this,dungeons,dungeonController,adventureQuests,nextTick,characters::find,character->sessions.activeCharacter(character).isPresent(),character->characterParties.partyOf(character).isPresent());
adventureQuests.registerDungeonRewards(site->dungeons.find(site.id()).map(value->new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(value.maximumXp(),value.maximumCoins())).orElse(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.ZERO,java.math.BigInteger.ZERO)));
adventureQuests.registerDungeonSites(()->dungeons.snapshot().dungeons().stream().filter(value->dungeons.eligibleForFutureOffer(value.id())).map(value->new games.dmg.fantasystick.adventure.AdventureQuestState.Site(value.id(),games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON,value.name(),value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())).toList());
adventureQuests.registerDungeonAvailability(site->{var definition=dungeons.find(site.id());if(definition.isEmpty()||!definition.orElseThrow().enabled())return false;var catalog=adventureQuests.snapshot().catalog();return catalog==null||dungeons.runView(site.id(),catalog.revision()).map(run->!run.defeated()).orElse(true);});
adventureQuestController.registerResetParticipant(dungeonController);
adventureQuestController.registerDungeonRoomPresence((dungeon,location)->location.getWorld()!=null&&dungeons.find(dungeon).filter(value->value.world().equals(location.getWorld().getUID())).isPresent()&&dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ()).isPresent());
combatExperience.attachExcluded(entity->adventureQuestController.managed(entity)||dungeonController.managed(entity));
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|| armorerController.tagged(entity) || adventureQuestController.giver(entity));
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
@@ -567,7 +595,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);
@@ -580,6 +608,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
@@ -589,6 +618,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
"Admin character issue " + problem.operation() + " for player " + problem.player()
+ "; character " + problem.character(), problem.cause())));
admin.attachCharacterBooks(books);
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -596,6 +626,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
"Missing packaged fantasyfeedback command");
playerFeedbackCommand.setExecutor(feedbackCommand);
playerFeedbackCommand.setTabCompleter(feedbackCommand);
var playerParticlesCommand = java.util.Objects.requireNonNull(getCommand("fantasyparticles"),
"Missing packaged fantasyparticles command");
playerParticlesCommand.setExecutor(particlesCommand);
playerParticlesCommand.setTabCompleter(particlesCommand);
getServer().getPluginManager().registerEvents(crafting, this);
getServer().getPluginManager().registerEvents(guard, this);
getServer().getPluginManager().registerEvents(anvilNames, this);
@@ -609,9 +643,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(armorerController,this);
getServer().getPluginManager().registerEvents(armorShopMenus,this);
getServer().getPluginManager().registerEvents(adventureQuestController,this);
getServer().getPluginManager().registerEvents(dungeonController,this);
partyRegistrarController.install();
trainingDummyController.install();
armorerController.install();
dungeonController.install();
adventureQuestController.install();
var localArmorMarket=armorMarket;
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
@@ -650,7 +686,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());
@@ -676,7 +712,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingChat = chatNames; chatNames = null;
var closingAdmin = admin; admin = 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;
@@ -691,6 +729,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingAdventureQuests = adventureQuests; adventureQuests = null;
var closingAdventureQuestController = adventureQuestController; adventureQuestController = null;
adventureQuestCommands = 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;
@@ -783,6 +822,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);
@@ -822,6 +862,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingAdventureQuestSettings != null) cleanup("adventure quest configuration", closingAdventureQuestSettings::close);
if (closingAdventureQuests != null) cleanup("adventure quest persistence", closingAdventureQuests::close);
if (closingAdventureQuestController != null) cleanup("adventure quests", closingAdventureQuestController::close);
if (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);
@@ -850,6 +892,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
: closingAdventureQuestSettings.drained().toCompletableFuture(),
closingAdventureQuests == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdventureQuests.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)
@@ -959,6 +1003,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingPartyRegistrarMenus);
unregister(closingArmorerController);unregister(closingArmorShopMenus);
unregister(closingAdventureQuestController);
unregister(closingDungeonController);
unregister(closingCombatExperience);
unregister(closingHealthSteal);
unregister(closingFireball);
@@ -1239,8 +1284,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();
@@ -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); }
@@ -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() {
@@ -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); }
@@ -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); }
@@ -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)
@@ -48,14 +48,16 @@ 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."),
@@ -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() {
@@ -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) {
@@ -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);
}
}
@@ -9,18 +9,24 @@ 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;
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);}
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fantasystick adventure");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll replaces today's offers only when no quest or encounter is active. Run /fantasystick adventure reroll confirm to continue.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fantasystick adventure [reroll confirm]");return;}if(!authorized(sender))return;if(controller.hasActiveEncounter()){sender.sendMessage("Adventure reroll refused: an encounter is active.");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 reroll failed; the current catalog is unchanged.");return;}switch(result.status()){case ACTIVE->sender.sendMessage("Adventure reroll refused: an accepted quest is active.");case STALE->sender.sendMessage("Adventure reroll refused: the catalog changed; inspect it and try again.");case APPLIED->{controller.catalogRerolled();var catalog=result.catalog().orElseThrow();sender.sendMessage("Rerolled daily adventures: revision="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){sender.sendMessage("Usage: /fantasystick adventure-site <place <camp|champion> [name...]|remove <site-uuid>|list>");return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().isEmpty())sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" world="+v.world()+" x="+v.x()+" y="+v.y()+" z="+v.z()));}case"place"->{if(args.length<3||!(args[2].equals("camp")||args[2].equals("champion"))||!(sender instanceof Player player)){usageSite(sender);return;}String name=join(args,3);var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+").":"Adventure site save failed; desired state is unchanged.")));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}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()+" world="+v.world()+" x="+v.x()+" y="+v.y()+" z="+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()+").");}));}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(!sender.hasPermission(PERMISSION))return List.of();if(args.length==2&&args[0].equals("adventure"))return matching(List.of("reroll"),args[1]);if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion"),args[2]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("remove"))return matching(directory.snapshot().sites().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;private final AdventureAdminTeleport teleports;
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fantasystick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fantasystick adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fantasystick adventure [tracking [on|off]|reroll confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()));}case"place"->{if(args.length<3||!(args[2].equals("camp")||args[2].equals("champion"))||!(sender instanceof Player player)){usageSite(sender);return;}var placement=placement(args);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+".":"Adventure site save failed; desired state is unchanged.")));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{controller.removeGiverLoaded(id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("list","place","remove","teleport"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion"),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion")))return matching(List.of("4","16","64"),args[3]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),site.type()==AdventureQuestState.SiteType.CAMP?"monster camp":"champion",site.world(),site.x(),site.y(),site.z(),0,0,true);}
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
private record Placement(String name,AdventureQuestState.Arena arena){}
private static Placement placement(String[] args){if(args.length==3)return new Placement("",null);Integer first=whole(args[3]);if(first==null)return looksNumeric(args[3])?null:new Placement(join(args,3),null);if(args.length>4){Integer second=whole(args[4]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,5),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[4]))return null;}return first>=4&&first<=64?new Placement(join(args,4),AdventureQuestState.Arena.circle(first)):null;}
private static Integer whole(String value){try{return Integer.valueOf(value);}catch(NumberFormatException invalid){return null;}}
private static boolean looksNumeric(String value){return value.matches("[+-]?(?:\\d.*|\\.\\d+)");}
private static String join(String[] args,int start){return start>=args.length?"":String.join(" ",Arrays.copyOfRange(args,start,args.length));}
private static UUID parse(String value){try{var id=UUID.fromString(value);return id.toString().equals(value)?id:null;}catch(IllegalArgumentException invalid){return null;}}
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(v->v.startsWith(prefix)).distinct().sorted().toList();}
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fantasystick adventure-site <place <camp|champion> [name...]|remove <site-uuid>|list>");}
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fantasystick adventure-site <place <camp|champion> [<radius>|<x-blocks> <z-blocks>] [name...]|remove <site-uuid>|teleport <site-uuid>|list>");}
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fantasystick quest-giver <place [name...]|remove <giver-uuid>|list>");}
}
@@ -11,6 +11,7 @@ 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;
@@ -30,50 +31,64 @@ import org.bukkit.plugin.Plugin;
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 EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final AdventureQuestNavigation navigation;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
private final Consumer<Runnable> nextTick;private final Clock clock;private final java.util.random.RandomGenerator random;
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,menuSiteKey,menuCatalogKey;
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,menuSiteKey,menuCatalogKey,menuPageKey;
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;MenuHolder(UUID catalog){this.catalog=catalog;}@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 int radius,resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,int radius,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.radius=radius;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=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);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.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");}
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);}
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@Override public Inventory getInventory(){return null;}}
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this(plugin,directory,settings,sessions,parties,characters,progression,nextTick,clock,random,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);this.particles=Objects.requireNonNull(particles);navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");}
public 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 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);}
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 void catalogRerolled(){if(hasActiveEncounter())throw new IllegalStateException("Active adventure encounter");readyAt.clear();}
public AdventureQuestState.Arena arena(AdventureQuestState.Site site){return site.arenaOrDefault(settings.rules().radius());}
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){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{if(failure!=null||context(player)==null){player.sendMessage("Daily quests are unavailable.");return;}var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision()),Math.max(9,Math.min(54,((catalog.sites().size()+8)/9)*9)),net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(site==null?Material.BARRIER:site.type()==AdventureQuestState.SiteType.CAMP?Material.CAMPFIRE:Material.NETHERITE_SWORD);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);String state=site==null?"Unavailable":active!=null&&active.site().equals(siteId)?"Active — click to abandon":active!=null?"Another quest is active":directory.claimed(catalog.revision(),siteId,context.character,context.partyId)?"Completed / already claimed":"Available — click to accept";var rules=settings.rules();String kind=site!=null&&site.type()==AdventureQuestState.SiteType.CHAMPION?"Champion":"Monster camp";var rewardXp=active!=null&&active.site().equals(siteId)?active.rewardXp():site!=null&&site.type()==AdventureQuestState.SiteType.CHAMPION?rules.championRewardXp():rules.campRewardXp();var rewardCoins=active!=null&&active.site().equals(siteId)?active.rewardCoins():site!=null&&site.type()==AdventureQuestState.SiteType.CHAMPION?rules.championRewardCoins():rules.campRewardCoins();meta.lore(List.of(net.kyori.adventure.text.Component.text(kind),net.kyori.adventure.text.Component.text("Reward: "+rewardXp+" XP, "+rewardCoins+" coins"),net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()),net.kyori.adventure.text.Component.text(state)));meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}player.openInventory(inventory);}));}
public void open(Player player){open(player,0);}
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.max(9,((count+8)/9)*9);var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon (maximum)";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Maximum reward: ":"Reward: ";meta.lore(List.of(net.kyori.adventure.text.Component.text(kind),net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"),net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()),net.kyori.adventure.text.Component.text(state)));meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}player.openInventory(inventory);}));}
private 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 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())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()));}
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();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);if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(context.partyId!=null&&!context.leader){player.sendMessage("Only the active party leader may accept a party quest.");return;}var type=context.partyId==null?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY;var unit=context.partyId==null?context.character:context.partyId;directory.accept(catalog,site,type,unit,context.members).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var location=directory.findSite(site).orElseThrow();player.sendMessage("Accepted "+location.displayName()+" at "+location.world()+" "+location.x()+", "+location.y()+", "+location.z()+"; expires "+result.quest().orElseThrow().expiresAt()+".");}));}
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()+" at "+site.world()+" "+site.x()+", "+site.y()+", "+site.z()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+".");}
private void pulse(){if(closed)return;directory.ensureToday();directory.expireParties(parties::exists);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.radius).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){if(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 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.radius)){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){return eligibleInside(site,settings.rules().radius());}
private java.util.stream.Stream<Player> eligibleInside(UUID site,int radius){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,radius));}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}if(context.partyId!=null&&!context.leader){player.sendMessage("Only the active party leader may accept a party quest.");return;}var type=context.partyId==null?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY;var unit=context.partyId==null?context.character:context.partyId;directory.accept(catalog,site,type,unit,context.members).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+result.quest().orElseThrow().expiresAt()+". Follow the Quest Compass.");}));}
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()){if(directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
private void 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)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(),rules.radius(),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(),rules.radius(),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 start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
@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.radius);if(!recipients.isEmpty())award(quest,recipients);} }
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){return recipients(quest,site,settings.rules().radius());}
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,int radius){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,radius))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
private void award(AdventureQuestState.Quest quest,List<UUID> recipients){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var work=recipients.stream().map(character->awardOne(quest,character,xp.get(character),coins.get(character))).toArray(CompletableFuture[]::new);CompletableFuture.allOf(work).whenComplete((ignored,failure)->{if(failure==null)directory.complete(quest.id(),Set.copyOf(recipients));else plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward failed for quest "+quest.id(),failure);});}
private java.util.concurrent.CompletableFuture<Void> awardOne(AdventureQuestState.Quest quest,UUID character,BigInteger xp,BigInteger coins){var result=new java.util.concurrent.CompletableFuture<Void>();characters.find(character).whenComplete((found,failure)->{if(failure!=null||found.isEmpty()){result.completeExceptionally(failure==null?new IllegalStateException("Character unavailable"):failure);return;}attemptAward(quest,found.orElseThrow(),xp,coins,result);});return result;}
private void attemptAward(AdventureQuestState.Quest quest,games.dmg.fantasystick.persistence.CharacterRecord record,BigInteger xp,BigInteger coins,java.util.concurrent.CompletableFuture<Void> result){UUID receipt=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.unit()+":"+record.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));characters.awardCombatReward(record.id(),record.revision(),receipt,xp,coins,progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){result.completeExceptionally(failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptAward(quest,award.record().orElseThrow(),xp,coins,result);return;}if(award.record().isEmpty()){result.completeExceptionally(new IllegalStateException("Reward unavailable"));return;}var saved=award.record().orElseThrow();schedule(()->{var active=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(active!=null)sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)player.sendMessage("Adventure complete: earned "+xp+" fantasy XP and "+coins+" character coins.");result.complete(null);});});}
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients);} }
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){var value=directory.findSite(site).orElse(null);return value==null?List.of():recipients(quest,site,arena(value));}
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,arena))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
private void award(AdventureQuestState.Quest quest,List<UUID> recipients){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
@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());}}
@@ -91,14 +106,18 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
@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.radius).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 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()){sessions.restoreBook(active);return;}var party=parties.partyOf(player.getUniqueId()).orElse(null);UUID partyId=party==null?null:party.id();var quest=directory.activeFor(record.id(),partyId).filter(value->value.snapshot().contains(record.id())).orElse(null);var site=quest==null?null:directory.findSite(quest.site()).filter(directory::available).orElse(null);if(quest==null||site==null||!directory.trackingEnabled(quest.id(),record.id())){sessions.restoreBook(active);return;}var location=player.getLocation().clone();boolean arrived=site.type()==AdventureQuestState.SiteType.DUNGEON?dungeonRoomPresence.test(site.id(),location):inside(location,site,arena(site));boolean published=sessions.presentBook(active,book->navigation.compass(book,quest,site,location));if(published&&actionBar)navigation.guidance(player,site,arrived).ifPresent(player::sendActionBar);}
private void 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,int radius){return location.getWorld()!=null&&location.getWorld().getUID().equals(site.world())&&location.distanceSquared(new Location(location.getWorld(),site.x(),site.y(),site.z()))<=radius*(double)radius;}
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(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
}
@@ -3,6 +3,7 @@ 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.*;
@@ -12,39 +13,71 @@ import org.bukkit.entity.EntityType;
/** Serialized durable daily catalog, acceptance, roster and completion authority. */
public final class AdventureQuestDirectory implements AutoCloseable {
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, CONFLICT, CLAIMED }
public enum RerollStatus { APPLIED, ACTIVE, STALE }
public enum RerollStatus { APPLIED, STALE }
public enum TrackingStatus { APPLIED, UNCHANGED, INELIGIBLE }
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
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");}}
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 boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
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 Optional<AdventureQuestState.Site> findSite(UUID id){return snapshot.sites().stream().filter(v->v.id().equals(id)).findFirst();}
public void registerDungeonRewards(java.util.function.Function<AdventureQuestState.Site,Reward> rewards){dungeonRewards=Objects.requireNonNull(rewards);}
public void registerDungeonAvailability(java.util.function.Predicate<AdventureQuestState.Site> available){dungeonAvailable=Objects.requireNonNull(available);}
public void registerDungeonSites(java.util.function.Supplier<? extends Collection<AdventureQuestState.Site>> sites){dungeonSites=Objects.requireNonNull(sites);}
public boolean available(AdventureQuestState.Site site){return site.type()!=AdventureQuestState.SiteType.DUNGEON||dungeonAvailable.test(site);}
public boolean offerOpen(UUID site){var catalog=snapshot.catalog();return catalog!=null&&catalog.sites().contains(site)&&!catalog.closed().contains(site);}
public Reward reward(AdventureQuestState.Site site){var rules=settings.get();return switch(site.type()){case CAMP->new Reward(rules.campRewardXp(),rules.campRewardCoins());case CHAMPION->new Reward(rules.championRewardXp(),rules.championRewardCoins());case DUNGEON->dungeonRewards.apply(site);};}
public 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){var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z);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<Optional<AdventureQuestState.Site>> removeSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)).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<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<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> ensureToday(){return mutate(state->{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var catalog=roll(state,today,state.catalog()==null?Set.of():Set.copyOf(state.catalog().sites()));var next=copy(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of());return result(next,catalog);});}
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(!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));if(!state.quests().isEmpty())return result(state,new RerollResult(RerollStatus.ACTIVE,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 next=copy(state,state.sites(),state.givers(),catalog,List.of(),sameDay?state.unitClaims():Set.of(),sameDay?state.characterClaims():Set.of(),Map.of());return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){return mutate(original->{var state=expire(original);var current=state.catalog();if(current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!current.sites().contains(site)||find(state.sites(),site).isEmpty())return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=members.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||type==AdventureQuestState.UnitType.PARTY&&q.unitType()==AdventureQuestState.UnitType.SOLO&&members.contains(q.unit()));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var rules=settings.get();var siteType=find(state.sites(),site).orElseThrow().type();var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,members,current.expiresAt(),siteType==AdventureQuestState.SiteType.CAMP?rules.campRewardXp():rules.championRewardXp(),siteType==AdventureQuestState.SiteType.CAMP?rules.campRewardCoins():rules.championRewardCoins());var quests=new ArrayList<>(state.quests());quests.add(quest);return result(copy(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters()),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
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){return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!current.sites().contains(site)||current.closed().contains(site)||site(state,site).filter(this::available).isEmpty())return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=members.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||type==AdventureQuestState.UnitType.PARTY&&q.unitType()==AdventureQuestState.UnitType.SOLO&&members.contains(q.unit()));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var offered=site(state,site).orElseThrow();var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,members,current.expiresAt(),reward.xp(),reward.coins());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:members)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
public CompletableFuture<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.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<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.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.site().equals(site)&&claim.character().equals(character))||(party!=null&&state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==AdventureQuestState.UnitType.PARTY&&claim.unit().equals(party)));}
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 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>> 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 copy(state,state.sites(),state.givers(),state.catalog(),List.of(),state.unitClaims(),state.characterClaims(),state.rosters());}
private AdventureQuestState.Catalog roll(AdventureQuestState state,LocalDate day,Set<UUID> previous){var alternatives=state.sites().stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=state.sites().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(),state.sites().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 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 ignored,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);}
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){var retainedIds=rewards.operations().stream().flatMap(value->value.allocations().stream()).collect(java.util.stream.Collectors.toSet());return new AdventureQuestState.RewardLedger(rewards.allocations().stream().filter(value->retainedIds.contains(value.id())).toList(),rewards.operations(),Set.of());}
private static 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;}
@@ -0,0 +1,61 @@
package games.dmg.fantasystick.adventure;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
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;
/** 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"};
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 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";};}
private static String worldName(World world){return world==null||world.getName()==null||world.getName().isBlank()?"unavailable world":world.getName();}
}
@@ -9,19 +9,36 @@ 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) {
public enum SiteType { CAMP, CHAMPION }
Map<UUID,List<EntityType>> rosters,Set<Tracking> tracking,RewardLedger rewards,Reset reset) {
public enum SiteType { CAMP, CHAMPION, DUNGEON }
public enum UnitType { SOLO, PARTY }
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z) {
public enum ArenaShape { CIRCLE, RECTANGLE }
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) {
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z); }
public String displayName(){return name.isBlank()?type==SiteType.CAMP?"Monster Camp":"Champion Site":name;}
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null);}
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";}:name;}
public 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) {
public Catalog { Objects.requireNonNull(day);Objects.requireNonNull(revision);Objects.requireNonNull(createdAt);sites=List.copyOf(sites);if(new HashSet<>(sites).size()!=sites.size())throw new IllegalArgumentException("Duplicate offer"); }
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,
@@ -34,12 +51,33 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
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);
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());}
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);
}
@@ -29,10 +29,14 @@ 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 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 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 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 +46,9 @@ 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 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 +94,76 @@ 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);
}
@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);
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));
}
});
}
private void load(ControlInput input, int offset) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
@@ -128,18 +200,33 @@ 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);
}
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);
}
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 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);
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));
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::category)
@@ -151,14 +238,14 @@ 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);
.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));
@@ -208,15 +295,29 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
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 {
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(interactive
? partyControls.pages(input) : partyControls.readOnlyPages(subject.getUniqueId()));
meta.pages(pages);
item.setItemMeta(meta);
return item;
@@ -225,12 +326,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
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> overviewPages(CharacterRecord record, org.bukkit.entity.Player subject,
StatView view) {
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);
}
@@ -241,7 +343,7 @@ 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()
builder.text("Health: " + java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString());
return builder.pages();
}
@@ -362,7 +464,8 @@ 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);
@@ -387,7 +490,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
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();
@@ -475,6 +579,28 @@ 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 + "\"")));
}
@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())
@@ -493,7 +619,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. */ }
}
@@ -652,6 +782,8 @@ 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);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
@@ -660,6 +792,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(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
}
}
@@ -54,6 +54,12 @@ 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));
}
private List<Component> render(ControlInput input, Menu menu, int offset) {
if (closed || !input.stillCurrent()) return List.of();
var build = new Pages(input);
@@ -61,18 +67,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 +103,26 @@ public final class PartyBookControls implements AutoCloseable {
return List.copyOf(build.pages);
}
private List<Component> rootPages(UUID owner) {
var pages = new ArrayList<Component>();
var party = parties.partyOf(owner);
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} 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));
}
@@ -0,0 +1,64 @@
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"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}var location=player.getLocation();String name=join(args,2);if(name.isBlank()){usage(sender);return;}directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+").":"Dungeon creation failed; desired state is unchanged.")));}
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; rooms="+value.rooms().size()+"; maximum="+value.maximumXp()+" XP, "+value.maximumCoins()+" coins."));}
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->directory.remove(id)).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){sender.sendMessage(controller.done(player.getUniqueId())?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}controller.start(player,id,args.length==5).whenComplete((status,failure)->respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});}));}
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; reward="+v.reward().xp()+" XP, "+v.reward().coins()+" coins; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}directory.removeRoom(dungeon,room).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"reward"->reward(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
private void roomCreate(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length<6){usage(sender);return;}var location=player.getLocation();int x=location.getBlockX(),y=location.getBlockY()-1,z=location.getBlockZ();if(args[3].equals("corner")){if(args.length<7){usage(sender);return;}Integer xs=integer(args[4]),zs=integer(args[5]),height=integer(args[6]);if(xs==null||zs==null||height==null){usage(sender);return;}directory.addCornerRoom(dungeon,join(args,7),x,y,z,xs,zs,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}if(args[3].equals("radius")){Integer radius=integer(args[4]),height=integer(args[5]);if(radius==null||height==null){usage(sender);return;}directory.addRadiusRoom(dungeon,join(args,6),x,y,z,radius,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}usage(sender);}
private void roomSaved(CommandSender sender,UUID dungeon,DungeonState.Room value,Throwable failure){if(failure==null)controller.definitionChanged(dungeon);String message=failure==null?"Created room "+value.id()+" ("+value.displayName()+").":roomFailure(failure);respond(sender,()->sender.sendMessage(message));}
private static String roomFailure(Throwable failure){Throwable cause=failure;while((cause instanceof java.util.concurrent.CompletionException||cause instanceof java.util.concurrent.ExecutionException)&&cause.getCause()!=null)cause=cause.getCause();String detail=cause.getMessage();if(cause instanceof IllegalArgumentException&&"Rooms overlap".equals(detail))return "Room creation refused: it overlaps an existing room.";if(cause instanceof IllegalArgumentException&&detail!=null&&detail.startsWith("Room exceeds configured link distance of ")){String blocks=detail.substring("Room exceeds configured link distance of ".length()).replace(" blocks","");return "Room creation refused: nearest room is farther than the configured "+blocks+"-block link distance.";}if(cause instanceof IllegalArgumentException&&(detail!=null&&(detail.equals("Room dimensions out of range")||detail.equals("Room limit reached"))))return "Room creation refused: dimensions or room limit are out of range.";if(cause instanceof java.io.IOException)return "Room creation failed: persistence is unavailable; desired state is unchanged.";return "Room creation refused: geometry validation failed; desired state is unchanged.";}
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeMob(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}directory.addEffect(dungeon,room.id(),target,key,level,particles).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeEffect(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
private void reward(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length!=6||!args[3].equals("set")){usage(sender);return;}var room=standing(sender,player);if(room==null)return;BigInteger xp=whole(args[4]),coins=whole(args[5]);if(xp==null||coins==null){usage(sender);return;}directory.setReward(dungeon,room.id(),xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set room reward to "+xp+" XP and "+coins+" coins."));}
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){directory.clearBoss(dungeon,room.id()).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var character=sheets.identify(held).orElse(null);if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||sheets.identify(current).filter(character::equals).isEmpty()){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}directory.prepareRecovery(dungeon,player.getUniqueId()).whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;directory.completeRecovery(dungeon,operation).whenComplete((completed,completeFailure)->respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");}));}));}
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){sender.sendMessage("Dungeon not found.");return;}teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target));}
private static AdventureAdminTeleport.Target target(DungeonState.Dungeon dungeon){var entrance=dungeon.entrance();return new AdventureAdminTeleport.Target(dungeon.id(),dungeon.name(),"dungeon entrance",dungeon.world(),entrance.x(),entrance.y(),entrance.z(),entrance.yaw(),entrance.pitch(),false);}
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate rooms, boss, overlap and persistence.")));}
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.disable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value?"Disabled and closed dungeon "+id+".":"Dungeon not found.")));}
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.open(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached.")));}
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.closeOffer(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open.")));}
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","open","remove","room","teleport"):List.of("close","disable","enable","list","open","remove"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&Set.of("close","disable","enable","open","remove","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove","reward"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("reward"))return matching(List.of("set"),args[3]);if((args.length==5||args.length==6)&&args[1].equals("room")&&args[2].equals("reward")&&args[3].equals("set"))return matching(List.of("0","10","100","2147483647"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
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 void usage(CommandSender sender){sender.sendMessage("Usage: /fantasystick dungeon <create|list|remove|edit|entrance|room|boss|enable|disable|open|close|teleport> ...");}
}
@@ -0,0 +1,155 @@
package games.dmg.fantasystick.dungeon;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.adventure.AdventureQuestState;
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 static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target;long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
private record Context(UUID character,UUID party,Set<UUID> members){}
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior){}
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final NamespacedKey typeKey,dungeonKey,roomKey,slotKey,generationKey,bossKey;
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
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 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);}
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;dungeons.defeatSlot(dungeonId,generation,slot).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;if(room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt.generation,quest,boss)).toArray(CompletableFuture[]::new);var completion=CompletableFuture.allOf(awards).thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id())).thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation):CompletableFuture.completedFuture(run)).thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run)).whenComplete((run,failure)->schedule(()->{completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}cleanupAttempt(key);if(boss)cleanupDungeon(dungeon.id());}));inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));}
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(room.reward().xp(),List.of(unit),random);var coins=RewardAllocator.allocate(room.reward().coins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));UUID source=UUID.nameUUIDFromBytes((generation+":"+dungeon.id()+":"+room.id()+":"+quest.id()).getBytes(StandardCharsets.UTF_8));return adventures.bank(quest.id(),source,rewards).thenCompose(banked->{if(!banked)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon reward banking refused"));return completeQuest?adventures.complete(quest.id(),Set.copyOf(recipients)):CompletableFuture.completedFuture(true);}).thenCompose(completed->{if(!completed)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon quest completion refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->null);}
private 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,73 @@
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){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of());return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
public CompletableFuture<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> setReward(UUID id,UUID roomId,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),value,room.boss()),value);}
public CompletableFuture<DungeonState.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);}
public boolean eligibleForFutureOffer(UUID id){var dungeon=find(id).orElse(null);if(dungeon==null||!dungeon.enabled())return false;try{validate(dungeon);return true;}catch(IllegalArgumentException invalid){return false;}}
private void validate(DungeonState.Dungeon dungeon){if(overlapping(dungeon.rooms()))throw new IllegalArgumentException("Dungeon rooms overlap");if(dungeon.rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(value->value.recovery()!=null))throw new IllegalArgumentException("Character recovery is pending");if(dungeon.rooms().isEmpty()||!connected(dungeon.rooms(),linkDistance()))throw new IllegalArgumentException("Dungeon rooms are incomplete");if(dungeon.rooms().stream().filter(v->v.boss()!=null).count()!=1)throw new IllegalArgumentException("Dungeon needs exactly one boss");}
private 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,62 @@
package games.dmg.fantasystick.dungeon;
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) {
public Dungeon {Objects.requireNonNull(id);name=validName(name);if(name.isBlank())throw new IllegalArgumentException("Dungeon name required");Objects.requireNonNull(world);Objects.requireNonNull(entrance);Objects.requireNonNull(revision);rooms=List.copyOf(rooms);unique(rooms.stream().map(Room::id).toList());if(rooms.size()>128)throw new IllegalArgumentException("Dungeon room limit exceeded");}
public BigInteger maximumXp(){return rooms.stream().map(v->v.reward().xp()).reduce(BigInteger.ZERO,BigInteger::add);}
public BigInteger maximumCoins(){return rooms.stream().map(v->v.reward().coins()).reduce(BigInteger.ZERO,BigInteger::add);}
}
public record 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");}
}
@@ -17,6 +17,7 @@ public final class CharacterSheets {
public static final NamespacedKey MODEL = new NamespacedKey("fantasystick", "character_sheet");
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
private static final NamespacedKey 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."));
@@ -50,6 +51,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;
@@ -145,7 +145,7 @@ public final class SessionControls {
private boolean validKind(Material material, int slot, PersistentDataContainer data) {
String kind = data.get(KIND, PersistentDataType.STRING);
if (slot == 0) return kind.equals("book") && material == Material.BOOK && !data.has(ABILITY);
if (slot == 0) return kind.equals("book") && (material == Material.BOOK || material == Material.COMPASS) && !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;
@@ -63,6 +62,7 @@ public final class CombatExperience implements Listener, 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 final CombatSources sources;
private final CombatContributions contributions;
private final PartyRoster parties;
@@ -98,7 +98,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,
@@ -106,12 +107,22 @@ 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);
}
@@ -313,7 +324,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) {
@@ -323,7 +335,8 @@ 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.");
}
}
@@ -16,18 +16,27 @@ 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", CAMP_ENEMIES="adventure-camp-enemies",
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";
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(CAMP_ENEMIES,"8"),
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"));
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) { }
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance) {
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);
}
}
public record Update(String key,String value,Map<String,String> values,long sequence) {
public Update { values=Map.copyOf(values); }
}
@@ -54,7 +63,8 @@ public final class AdventureQuestSettings implements AutoCloseable {
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)));}
whole(values.get(CHAMPION_REWARD_XP)),whole(values.get(CHAMPION_REWARD_COINS)),
bounded(values.get(DUNGEON_ROOM_LINK_DISTANCE),1,256));}
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();}
@@ -43,6 +43,8 @@ public final class CharacterStorage implements AutoCloseable {
this.coinRandom = Objects.requireNonNull(coinRandom);
}
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){repository.attachUnavailable(predicate);}
public CompletableFuture<Void> create(CharacterRecord character) {
return submit(() -> {
repository.create(character);
@@ -376,7 +378,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());
repository.replace(saved, old.revision());
return new CombatAward(status, Optional.of(saved), gained);
});
@@ -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,7 +27,7 @@ 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());
@@ -67,7 +67,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");
@@ -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);
@@ -21,27 +21,44 @@ public final class YamlAdventureQuestRepository {
if(Files.notExists(file))return AdventureQuestState.empty();
try{
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);if(number(root.get("schema-version"))!=1)throw new IllegalArgumentException();
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);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")));}
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena));}
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());}
AdventureQuestState.Catalog catalog=null;if(root.get("catalog")!=null){var r=map(root.get("catalog"));catalog=new AdventureQuestState.Catalog(LocalDate.parse(text(r.get("day"))),uuid(r,"revision"),Instant.parse(text(r.get("created-at"))),list(r.get("sites")).stream().map(value->UUID.fromString(text(value))).toList(),r.containsKey("closed")?list(r.get("closed")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()):Set.of());}
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins")))));}
var 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());}
return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters);
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 void save(AdventureQuestState state)throws IOException{
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",1);
root.put("sites",state.sites().stream().sorted(Comparator.comparing(AdventureQuestState.Site::id)).map(v->row("id",v.id(),"type",v.type(),"name",v.name(),"world",v.world(),"x",v.x(),"y",v.y(),"z",v.z())).toList());
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()));else root.put("catalog",null);
if(state.catalog()!=null)root.put("catalog",row("day",state.catalog().day(),"revision",state.catalog().revision(),"created-at",state.catalog().createdAt(),"sites",state.catalog().sites(),"closed",state.catalog().closed().stream().sorted().toList()));else root.put("catalog",null);
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins())).toList());
root.put("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);}
}
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z());if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
private static Map<String,Object> row(Object...pairs){var row=new LinkedHashMap<String,Object>();for(int i=0;i<pairs.length;i+=2)row.put((String)pairs[i],encode(pairs[i+1]));return row;}
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof Instant||value instanceof LocalDate||value instanceof BigInteger||value instanceof Double||value instanceof Float)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlAdventureQuestRepository::encode).toList();return value;}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(64);options.setCodePointLimit(4_194_304);return new Yaml(new SafeConstructor(options));}
@@ -22,12 +22,15 @@ 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;
@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);}
public YamlCharacterRepository(Path pluginDataDirectory) {
this(pluginDataDirectory, (source, target) -> Files.move(source, target,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
@@ -68,10 +71,12 @@ public final class YamlCharacterRepository {
}
public void replace(CharacterRecord character, BigInteger expectedRevision) throws IOException {
if (unavailable.test(character.id())) throw new IOException("Character is in dungeon boss 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");
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
write(character);
@@ -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)));if(integer(root.get("schema-version"))!=1)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms));}
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated"))));}
return new DungeonState(dungeons,runs);
}catch(RuntimeException invalid){throw new IOException("Invalid dungeon file",invalid);}
}
public void save(DungeonState state)throws IOException{Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",1);root.put("dungeons",state.dungeons().stream().sorted(Comparator.comparing(DungeonState.Dungeon::id)).map(YamlDungeonRepository::dungeon).toList());root.put("runs",state.runs().stream().sorted(Comparator.comparing(v->v.dungeon().toString()+v.generation())).map(YamlDungeonRepository::run).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"dungeons-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic dungeon persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
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;}
}
@@ -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) { }
}
}
@@ -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); }
@@ -72,6 +72,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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;
@@ -91,6 +92,7 @@ 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;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -330,6 +332,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed || adventureQuestCommands != null) throw new IllegalStateException("Adventure quests already attached or command closed");
adventureQuestCommands = Objects.requireNonNull(commands); adventureQuestSettings = Objects.requireNonNull(settings);
}
public void 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) {
@@ -377,6 +380,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterIssuer = Objects.requireNonNull(issuer);
}
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books) {
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
characterBooks = Objects.requireNonNull(books);
}
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");
@@ -413,6 +421,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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")) {
characterView(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;
@@ -494,6 +506,23 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
private void characterView(CommandSender sender, String[] args) {
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)
|| args.length != 3 || !args[1].equals("view") || args[2].isBlank()) {
sender.sendMessage("Usage: /fantasystick character view <online-player> (in-game administrator only)");
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 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;
@@ -1433,9 +1462,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
}
if (!sender.hasPermission(PERMISSION)) return List.of();
if(dungeonCommands!=null&&args.length>1){var dungeon=dungeonCommands.complete(sender,args);if(!dungeon.isEmpty())return dungeon;if(args[0].equals("dungeon"))return List.of();}
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("adventure", "adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
: List.of("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "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") && sender instanceof org.bukkit.entity.Player)
return matching(List.of("view"), args[1]);
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(
@@ -752,7 +752,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;
@@ -113,7 +114,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 +177,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);
@@ -276,6 +287,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());
@@ -382,7 +431,12 @@ 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;
}
@@ -391,6 +445,10 @@ public final class EquipmentSessions implements AutoCloseable {
notice(owner, "INVALID_SHEET", "Hold a valid live character sheet in your off hand.");
return;
}
if (unavailableCharacter.test(character.orElseThrow())) {
notice(owner, "CHARACTER_IN_CUSTODY", "That character sheet is assigned to a dungeon boss and cannot be activated.");
return;
}
var attempt = new Attempt(player, owner, character.orElseThrow());
if (adventureMode.getAsBoolean() && player.getGameMode() != org.bukkit.GameMode.ADVENTURE) {
attempt.previousGameMode = player.getGameMode().name();
@@ -442,6 +500,9 @@ 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;
}
@@ -454,8 +515,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 +683,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 +719,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 +746,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 +814,24 @@ 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();
return attempt.deathReference != null && attempt.deathNativeReference != null
&& Objects.equals(attempt.deathReference, copy(current))
&& nativeIdentity(current) == attempt.deathNativeReference
&& sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
&& 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 +840,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 +986,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");
}
});
}
@@ -948,6 +1091,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);
@@ -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; }
}
@@ -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();
}
+2
View File
@@ -64,6 +64,8 @@ restricted-kill-spacing-blocks: 3
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
+4 -1
View File
@@ -8,11 +8,14 @@ author: dmg.games
commands:
fantasystick:
description: Inspect daily adventures or administer Fantasy Stick.
usage: /fantasystick adventure [reroll confirm] | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|list> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
usage: /fantasystick adventure [reroll confirm] | character view <online-player> | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
fantasyfeedback:
description: Read or change routine Fantasy Stick combat and ability feedback for your player account.
usage: /fantasyfeedback [on|off]
fantasyparticles:
description: Read or change Fantasy Stick particle visibility for your player account.
usage: /fantasyparticles <all|players> [on|off]
permissions:
fantasystick.admin:
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
@@ -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("view"),
fixture.command.tabComplete(admin.player, "fantasystick", 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();
assertTrue(fixture.command.tabComplete(console.port, "fantasystick",
new String[]{"character", ""}).isEmpty());
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);
}
}
@@ -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);
@@ -21,6 +21,86 @@ 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"),
@@ -28,7 +108,7 @@ class AdventureQuestNativeTest {
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);
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>();
@@ -37,7 +117,7 @@ class AdventureQuestNativeTest {
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","Dawn Camp"}); drainOne(f);
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());
@@ -46,28 +126,410 @@ class AdventureQuestNativeTest {
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();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();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();
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"});drainOne(f);assertNotEquals(originalCatalog.revision(),directory.snapshot().catalog().revision());assertTrue(actor.messages.getLast().contains("Rerolled")&&actor.messages.getLast().contains("expires"));
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.contains("Accepted")&&message.contains("Dawn Camp")));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure"});assertTrue(actor.messages.getLast().contains("Dawn Camp"));assertTrue(actor.messages.getLast().contains("0.0"));
assertTrue(actor.messages.stream().anyMatch(message -> message.equals(
"Accepted Dawn Camp; expires " + directory.active().getFirst().expiresAt()
+ ". Follow the Quest Compass.")));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure"});
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();drainOne(f);
var enemies=spawned.stream().filter(entity->!(entity instanceof Villager)).toList();assertEquals(4,enemies.size());var activeCatalog=directory.snapshot().catalog();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});assertEquals(activeCatalog,directory.snapshot().catalog());assertTrue(actor.messages.getLast().contains("active"));
var enemy=enemies.getFirst();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());
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<4;retry++)drainOne(f);
var enemies=spawned.stream().filter(entity->!(entity instanceof Villager)).toList();assertEquals(4,enemies.size());assertTrue(controller.hasActiveEncounter());
var enemy=enemies.getFirst();when(enemy.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+11,siteRecord.y(),siteRecord.z()));when(actor.world.getEntities()).thenReturn(new ArrayList<Entity>(spawned));for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(enemy).teleport(argThat((Location location)->location.getX()==siteRecord.x()&&location.getY()==siteRecord.y()&&location.getZ()==siteRecord.z()));
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);var outsiderSource=mock(org.bukkit.damage.DamageSource.class);when(outsiderSource.getCausingEntity()).thenReturn(outsider.player);when(outsiderSource.getDirectEntity()).thenReturn(outsider.player);var refusedHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(outsider.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,outsiderSource,1);f.dispatcher.accept(refusedHit);assertTrue(refusedHit.isCancelled());var enemySource=mock(org.bukkit.damage.DamageSource.class);when(enemySource.getCausingEntity()).thenReturn(enemy);when(enemySource.getDirectEntity()).thenReturn(enemy);var refusedAttack=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,1);f.dispatcher.accept(refusedAttack);assertTrue(refusedAttack.isCancelled());var targetEvent=new org.bukkit.event.entity.EntityTargetLivingEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_PLAYER);f.dispatcher.accept(targetEvent);assertTrue(targetEvent.isCancelled());
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var attemptsField=controller.getClass().getDeclaredField("attempts");attemptsField.setAccessible(true);var attempts=(Map<?,?>)attemptsField.get(controller);assertEquals(1,attempts.size());var attempt=attempts.values().iterator().next();var representedField=attempt.getClass().getDeclaredField("represented");representedField.setAccessible(true);assertEquals(1,((Set<?>)representedField.get(attempt)).size());var recipientsMethod=controller.getClass().getDeclaredMethod("recipients",games.dmg.fantasystick.adventure.AdventureQuestState.Quest.class,UUID.class);recipientsMethod.setAccessible(true);var activeQuest=directory.active().getFirst();assertEquals(List.of(actor.core.record.id()),recipientsMethod.invoke(controller,activeQuest,activeQuest.site()));
for(var value:enemies){var death=new org.bukkit.event.entity.EntityDeathEvent(value,source,new ArrayList<>(),7);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());}
assertTrue(((Map<?,?>)attemptsField.get(controller)).isEmpty());
for(int i=0;i<50&&(directory.active().size()!=1||storage.find(actor.core.record.id()).get().orElseThrow().experience().signum()==0);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}
var saved=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(100),saved.experience());assertEquals(actorCoins.add(java.math.BigInteger.valueOf(20)),saved.coins());assertEquals(1,directory.active().size(),"Unrepresented party retains its quest");assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedA=storage.find(partyA.core.record.id()).get().orElseThrow();var savedB=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(100),savedA.experience().add(savedB.experience()));assertTrue(savedA.experience().signum()>0&&savedB.experience().signum()>0,"Present non-attacker shares party reward");assertEquals(java.math.BigInteger.valueOf(20),savedA.coins().subtract(partyACoins).add(savedB.coins().subtract(partyBCoins)));assertTrue(directory.active().isEmpty());var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.CLAIMED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
for(int i=0;i<50&&(directory.active().size()!=1||directory.unclaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()).xp().signum()==0||actor.core.inventory.getItem(0).getType()!=Material.BOOK);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}
var banked=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.ZERO,banked.experience());assertEquals(actorCoins,banked.coins());assertEquals(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),directory.unclaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()));assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType(),"completion restores the US-069 book control");assertEquals(1,directory.active().size(),"Unrepresented party retains its quest");assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());
int rewardMenuCount=actor.inventories.size();controller.open(actor.player);for(int retry=0;retry<10&&actor.inventories.size()==rewardMenuCount;retry++)drainOne(f);var rewardsMenu=actor.inventories.getLast();assertEquals(Material.PAPER,rewardsMenu.getItem(0).getType());assertTrue(rewardsMenu.getItem(0).getItemMeta().lore().stream().map(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()::serialize).anyMatch(line->line.contains("Rewards ready")),rewardsMenu.getItem(0).getItemMeta().lore().toString());var rewardClick=mock(InventoryClickEvent.class);when(rewardClick.getWhoClicked()).thenReturn(actor.player);when(rewardClick.getInventory()).thenReturn(rewardsMenu);when(rewardClick.getCurrentItem()).thenReturn(rewardsMenu.getItem(0));controller.click(rewardClick);for(int i=0;i<50&&directory.unclaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()).xp().signum()>0;i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(100),saved.experience());assertEquals(actorCoins.add(java.math.BigInteger.valueOf(20)),saved.coins());assertTrue(directory.rewardClaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()),"durable reward receipt missing");
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<priorEnemies+4;retry++)drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedA=storage.find(partyA.core.record.id()).get().orElseThrow();var savedB=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.ZERO,savedA.experience());assertEquals(java.math.BigInteger.ZERO,savedB.experience());var pendingA=directory.unclaimed(catalog.revision(),siteId,partyA.core.record.id());var pendingB=directory.unclaimed(catalog.revision(),siteId,partyB.core.record.id());assertEquals(java.math.BigInteger.valueOf(100),pendingA.xp().add(pendingB.xp()));assertTrue(pendingA.xp().signum()>0&&pendingB.xp().signum()>0,"Present non-attacker receives an independent banked share");assertEquals(java.math.BigInteger.valueOf(20),pendingA.coins().add(pendingB.coins()));assertTrue(directory.active().isEmpty());for(var member:List.of(partyA,partyB)){int memberMenuCount=member.inventories.size();controller.open(member.player);for(int retry=0;retry<10&&member.inventories.size()==memberMenuCount;retry++)drainOne(f);var memberMenu=member.inventories.getLast();assertEquals(Material.PAPER,memberMenu.getItem(0).getType());var memberClick=mock(InventoryClickEvent.class);when(memberClick.getWhoClicked()).thenReturn(member.player);when(memberClick.getInventory()).thenReturn(memberMenu);when(memberClick.getCurrentItem()).thenReturn(memberMenu.getItem(0));controller.click(memberClick);for(int retry=0;retry<50&&directory.unclaimed(catalog.revision(),siteId,member.core.record.id()).xp().signum()>0;retry++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}}savedA=storage.find(partyA.core.record.id()).get().orElseThrow();savedB=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(100),savedA.experience().add(savedB.experience()));assertEquals(java.math.BigInteger.valueOf(20),savedA.coins().subtract(partyACoins).add(savedB.coins().subtract(partyBCoins)));var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int retry=0;retry<15&&(directory.snapshot().catalog().revision().equals(completedCatalog.revision())||directory.snapshot().reset()!=null);retry++)drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
}
}
@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.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
String inspected = actor.messages.getLast();
assertEquals("Monster Camp; state=waiting; expires "+quest.expiresAt()+"; Quest Compass guidance=on.",inspected);
for(String hidden:List.of("(12, -1, -9)",worldId.toString(),site.id().toString(),quest.id().toString()))assertFalse(inspected.contains(hidden));
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
f.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
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.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","t"}));
assertEquals(List.of("off","on"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","tracking",""}));
assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","tracking","on","extra"}).isEmpty());
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking"});
assertTrue(actor.messages.getLast().contains("on"));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking","off"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.BOOK;retry++)drainOne(f);
assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType());
assertFalse(directory.trackingEnabled(quest.id(),actor.core.record.id()));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking","on"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.COMPASS;retry++)drainOne(f);
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());
}
}
@@ -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());
@@ -36,12 +36,12 @@ class ChampionQuestNativeTest {
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","Summit"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Guide"});drainOne(f);
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","champion","20","60","Summit"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Guide"});drainOne(f);
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var current=directory.snapshot();var snapshotField=directory.getClass().getDeclaredField("snapshot");snapshotField.setAccessible(true);snapshotField.set(directory,new games.dmg.fantasystick.adventure.AdventureQuestState(current.sites(),current.givers(),null,current.quests(),current.unitClaims(),current.characterClaims(),current.rosters()));directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();f.activate(actor);var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(actor.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);var menu=actor.inventories.getLast();var lore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(lore.contains("Champion"));assertTrue(lore.contains("150 XP, 40 coins"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(click);drainOne(f);
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);member.core.location.setX(0);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);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||storage.find(actor.core.record.id()).get().orElseThrow().experience().signum()==0);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(150),saved.experience().subtract(initial.experience()).add(savedMember.experience().subtract(memberInitial.experience())));assertEquals(java.math.BigInteger.valueOf(40),saved.coins().subtract(initial.coins()).add(savedMember.coins().subtract(memberInitial.coins())));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();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().add(java.math.BigInteger.valueOf(150)),savedSolo.experience());assertEquals(soloInitial.coins().add(java.math.BigInteger.valueOf(40)),savedSolo.coins());verify(f.bossBars.get(1),atLeastOnce()).removeAll();
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();f.activate(actor);var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(actor.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var lore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(lore.contains("Champion"));assertTrue(lore.contains("150 XP, 40 coins"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(click);drainOne(f);
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var siteRecord=directory.findSite(site).orElseThrow();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);member.core.location.setZ(siteRecord.z()+25);solo.core.location.setZ(siteRecord.z()+25);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);assertFalse(actor.particleCalls.isEmpty());assertFalse(member.particleCalls.isEmpty());assertFalse(solo.particleCalls.isEmpty());int memberParticles=member.particleCalls.size();member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);assertEquals(memberParticles,member.particleCalls.size());member.core.location.setX(siteRecord.x());for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst(),atLeast(2)).addPlayer(member.player);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-health","120"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","10"});drainOne(f);var liveSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");for(int retry=0;retry<10&&liveSettings.rules().championDamage()!=10;retry++)drainOne(f);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||directory.unclaimed(catalog.revision(),site,actor.core.record.id()).xp().add(directory.unclaimed(catalog.revision(),site,member.core.record.id()).xp()).signum()==0);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(initial.experience(),saved.experience());assertEquals(memberInitial.experience(),savedMember.experience());var partyRewardA=directory.unclaimed(catalog.revision(),site,actor.core.record.id());var partyRewardB=directory.unclaimed(catalog.revision(),site,member.core.record.id());assertEquals(java.math.BigInteger.valueOf(150),partyRewardA.xp().add(partyRewardB.xp()));assertEquals(java.math.BigInteger.valueOf(40),partyRewardA.coins().add(partyRewardB.coins()));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();int memberParticlesAfterCompletion=member.particleCalls.size();
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();assertEquals(memberParticlesAfterCompletion,member.particleCalls.size(),"completed party members stop receiving the shared boundary");var second=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).skip(1).findFirst().orElseThrow();assertEquals(2,f.bossBars.size());verify(maxHealth).setBaseValue(120);verify(attack).setBaseValue(10);verify(second).setHealth(120);var soloSource=mock(org.bukkit.damage.DamageSource.class);when(soloSource.getCausingEntity()).thenReturn(solo.player);when(soloSource.getDirectEntity()).thenReturn(solo.player);f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,second,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,soloSource,1));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,soloSource,new ArrayList<>(),0));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedSolo=storage.find(solo.core.record.id()).get().orElseThrow();assertEquals(soloInitial.experience(),savedSolo.experience());assertEquals(soloInitial.coins(),savedSolo.coins());assertEquals(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40)),directory.unclaimed(catalog.revision(),site,solo.core.record.id()));verify(f.bossBars.get(1),atLeastOnce()).removeAll();
}
}
private static void drainOne(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}
@@ -39,6 +39,108 @@ class CombatExperienceNativeTest {
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 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);
@@ -116,9 +218,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);
@@ -231,10 +331,11 @@ 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"));
@@ -259,13 +360,15 @@ class CombatExperienceNativeTest {
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
assertEquals(1, failures.size());
assertEquals(1, failures.size()); 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"));
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();
}
@@ -0,0 +1,134 @@
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);
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 registeredAuthoringQuestAcceptanceRoomRewardAndDirectBossClearAreConnected() 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);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
var actorEffects=new HashMap<org.bukkit.potion.PotionEffectType,org.bukkit.potion.PotionEffect>();doAnswer(call->Set.copyOf(actorEffects.values())).when(actor.player).getActivePotionEffects();doAnswer(call->{var effect=(org.bukkit.potion.PotionEffect)call.getArgument(0);actorEffects.put(effect.getType(),effect);return true;}).when(actor.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(call->{actorEffects.remove((org.bukkit.potion.PotionEffectType)call.getArgument(0));return null;}).when(actor.player).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.getName()).thenReturn("Dungeon Realm");when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
var spawned=new ArrayList<LivingEntity>();
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
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","edit","start",dungeon.id().toString());var dungeonController=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->dungeonController.editing(actor.core.id).isPresent(),actor.messages);
command(f,actor,"dungeon","room","create","corner","4","4","3","Entry");await(f,()->!dungeons.find(dungeon.id()).orElseThrow().rooms().isEmpty(),actor.messages);var first=dungeons.find(dungeon.id()).orElseThrow().rooms().getFirst();
command(f,actor,"dungeon","room","mob","add","husk","Guard A");command(f,actor,"dungeon","room","mob","add","husk","Guard B");command(f,actor,"dungeon","room","effect","add","players","speed","1","false");command(f,actor,"dungeon","room","reward","set","50","10");
actor.core.location.setX(actor.core.location.getBlockX()+4.2);
command(f,actor,"dungeon","room","create","corner","3","4","3","Boss");var bossRoom=dungeons.find(dungeon.id()).orElseThrow().rooms().getLast();
command(f,actor,"dungeon","room","reward","set","30","5");command(f,actor,"dungeon","room","boss","mob","vindicator","Keeper");
actor.particleCalls.clear();pulse(f);assertFalse(actor.particleCalls.isEmpty());assertTrue(actor.particleCalls.size()<=1024);assertTrue(actor.particleCalls.stream().allMatch(call->call[0]==Particle.DUST));var particlePreferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,false).toCompletableFuture().get(5,TimeUnit.SECONDS)));actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty(),"All-particle opt-out suppresses dungeon-editor boundaries");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,true).toCompletableFuture().get(5,TimeUnit.SECONDS)));var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);doReturn(true).when(outsider.player).hasPermission("fantasystick.admin");command(f,outsider,"dungeon","edit","start",dungeon.id().toString());assertTrue(outsider.messages.getLast().contains("editing lease"));assertTrue(outsider.particleCalls.isEmpty());
command(f,actor,"dungeon","edit","done");actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty());command(f,actor,"dungeon","enable",dungeon.id().toString());command(f,actor,"dungeon","open",dungeon.id().toString());
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");assertTrue(adventures.snapshot().catalog().sites().contains(dungeon.id()));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(80),BigInteger.valueOf(15)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow()));
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var before=storage.find(actor.core.record.id()).get().orElseThrow();var partyABefore=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBBefore=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();var survivor=initialMobs.getLast();var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);drain(f);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertEquals(partyBBefore.experience(),storage.find(partyB.core.record.id()).get().orElseThrow().experience(),"Late party member receives no retroactive room reward");
for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));
await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty(),actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(before.experience(),after.experience());assertEquals(before.coins(),after.coins());assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(80),BigInteger.valueOf(15)),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(partyABefore.experience(),partyAAfter.experience());assertEquals(partyBBefore.experience(),partyBAfter.experience());var bankedA=adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id());var bankedB=adventures.unclaimed(catalog.revision(),dungeon.id(),partyB.core.record.id());assertEquals(BigInteger.valueOf(80),bankedA.xp().add(bankedB.xp()));assertEquals(BigInteger.valueOf(15),bankedA.coins().add(bankedB.coins()));assertTrue(bankedA.xp().signum()>0&&bankedB.xp().signum()>0,"both present party members require banked shares");var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertTrue(dungeons.snapshot().runs().isEmpty(),"full reset retained dungeon run");command(f,actor,"dungeon","open",dungeon.id().toString());var resetCatalog=adventures.snapshot().catalog();assertTrue(resetCatalog.sites().contains(dungeon.id()),"reopened dungeon absent from replacement catalog");assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(resetCatalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS).status());command(f,actor,"dungeon","edit","start",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("exact confirm"),actor.messages.toString());assertFalse(adventures.active().isEmpty(),"fresh dungeon quest missing");command(f,actor,"dungeon","edit","start",dungeon.id().toString(),"confirm");await(f,()->dungeonController.editing(actor.core.id).isPresent(),actor.messages);assertTrue(adventures.active().isEmpty(),"confirmed edit retained accepted quest");assertFalse(dungeons.find(dungeon.id()).orElseThrow().enabled());
}
}
@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");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.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
when(admin.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());
var 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));}
}
@@ -53,7 +53,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("adventure-site","config","experience","give","inspect","party-registrar","quest-giver","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 +94,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("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "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", ""}));
@@ -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");
}
}
@@ -55,6 +56,7 @@ class FantasyFeedbackCommandNativeTest {
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(
@@ -82,19 +87,41 @@ class FantasyHealthNativeTest {
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)));
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,307 @@
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"})) {
assertFalse(fixture.particlesCommand.getExecutor().onCommand(player.player,
fixture.particlesCommand, "fantasyparticles", invalid));
}
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(14, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/adventure refresh and session particles");
assertEquals(15, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/adventure/dungeon refresh and session particles");
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
@@ -544,6 +544,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);
@@ -906,7 +922,8 @@ class FantasyStickLifecycleNativeTest {
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", "adventure-camp-enemies: 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",
@@ -1128,6 +1145,7 @@ class FantasyStickLifecycleNativeTest {
final Map<java.util.UUID, org.bukkit.entity.Player> players = new HashMap<>();
org.bukkit.command.PluginCommand command;
org.bukkit.command.PluginCommand feedbackCommand;
org.bukkit.command.PluginCommand particlesCommand;
int playerLookups;
String failRegistrationAt;
boolean failRemoval;
@@ -1210,7 +1228,11 @@ 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 "fantasyfeedback" -> feedbackCommand;
case "fantasyparticles" -> particlesCommand;
default -> command;
};
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]);
@@ -1250,6 +1272,13 @@ class FantasyStickLifecycleNativeTest {
feedbackCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
feedbackCommand.setUsage((String) feedbackDefinition.get("usage"));
}
var particlesDefinition = plugin.getDescription().getCommands().get("fantasyparticles");
if (particlesDefinition != null) {
particlesCommand = constructor.newInstance("fantasyparticles", plugin);
particlesCommand.setPermission((String) particlesDefinition.get("permission"));
particlesCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
particlesCommand.setUsage((String) particlesDefinition.get("usage"));
}
}
<T> List<T> packets(org.bukkit.entity.Player viewer, Class<T> type) {
@@ -1330,12 +1359,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<>();
@@ -1348,6 +1380,9 @@ class FantasyStickLifecycleNativeTest {
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 = () -> {};
@@ -1377,11 +1412,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);
@@ -1410,15 +1446,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; }
@@ -1444,6 +1492,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);
@@ -77,7 +77,15 @@ class FrostNovaNativeTest {
assertTrue(text.contains("FrostNova")); assertTrue(text.contains("INT16")); assertTrue(text.contains("DEX10"));
assertFalse(text.contains("Cost:")); assertTrue(text.contains("5")); assertTrue(text.contains("SlownessII"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "frost_nova")); drain(fixture, 2);
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();
use(fixture, caster); drain(fixture, 2);
assertTrue(caster.particleCalls.isEmpty(), "The opted-out caster receives no Frost Nova particles");
assertTrue(viewer.particleCalls.stream().anyMatch(call -> call[0] == org.bukkit.Particle.SNOWFLAKE
&& call[2].equals(32)), "An enabled nearby viewer retains the Frost Nova burst");
assertEquals(8, damage.get(enemy.getUniqueId()), 1.0e-9); assertEquals(8, damage.get(opponent.core.id), 1.0e-9);
assertEquals(2, damage.size(), "Duplicate, cancelled, party and ordinary targets cannot add accepted hits");
assertEquals(1, caster.messages.stream().filter("Frost Nova did 8 damage to Zombie."::equals).count());
@@ -52,7 +52,15 @@ class FrostShardNativeTest {
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 Frost Shard particles");
assertTrue(viewer.particleCalls.stream().anyMatch(call -> call[0] == org.bukkit.Particle.SNOWFLAKE
&& call[2].equals(8)), "An enabled nearby viewer retains the Frost Shard burst");
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
verify(a.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertEquals(new org.bukkit.util.Vector(3, 0, 0), velocity.get());
@@ -346,7 +346,7 @@ class LongbowNativeTest {
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"),
assertEquals(java.util.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(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
@@ -30,7 +30,7 @@ class MeteorNativeTest {
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);verify(caster.world,atLeast(40)).spawnParticle(eq(Particle.DUST),any(Location.class),eq(24),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));verify(caster.world,atLeastOnce()).spawnParticle(eq(Particle.EXPLOSION_EMITTER),any(Location.class),eq(2),anyDouble(),anyDouble(),anyDouble(),anyDouble());verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);f.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(f);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Meteor warning or impact particles");assertTrue(viewer.particleCalls.stream().filter(call->call[0]==Particle.DUST&&call[2].equals(24)).count()>=40);assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.EXPLOSION_EMITTER&&call[2].equals(2)));assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.FLAME&&call[2].equals(48)));verify(caster.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.ENTITY_GENERIC_EXPLODE),eq(1f),eq(.7f));verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
}
}
@@ -200,7 +200,7 @@ class QuickstepNativeTest {
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"),
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("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
@@ -40,8 +40,11 @@ class SanctuaryNativeTest {
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(fixture,caster);
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
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();
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Sanctuary particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==org.bukkit.Particle.END_ROD&&call[2].equals(12)),"An enabled nearby viewer retains Sanctuary visuals");
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(caster.world.getUID()).thenReturn(UUID.randomUUID());when(enemy.getLocation()).thenReturn(new Location(caster.world,8,64,0));
var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();
@@ -130,7 +130,7 @@ class SecondWindNativeTest {
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"),
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[]{""}));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
@@ -62,6 +62,20 @@ class SessionControlsNativeTest {
}
}
@Test
void questCompassRetainsOnlyTheAuthenticBookControlIdentityAcrossNativeCodec() {
var compass=controls.create(character(Collections.nCopies(7,"")),owner,session)[0].clone();
compass.setType(Material.COMPASS);
var meta=compass.getItemMeta();meta.displayName(Component.text("Quest Compass"));compass.setItemMeta(meta);
var reloaded=roundTrip(compass);
assertEquals(Material.COMPASS,reloaded.getType());
assertEquals(new SessionControls.Identity(owner,session,0),controls.identify(reloaded).orElseThrow());
assertTrue(controls.owned(reloaded,owner,session));
assertTrue(controls.ability(reloaded).isEmpty());
var forged=reloaded.clone();var forgedMeta=forged.getItemMeta();forgedMeta.getPersistentDataContainer().set(key("control_slot"),PersistentDataType.INTEGER,1);forged.setItemMeta(forgedMeta);
assertTrue(controls.identify(forged).isEmpty(),"a compass cannot impersonate another control slot");
}
@Test
void everyApprovedSavedAbilityUsesItsOwnStableIdAndCataloguePresentation() {
var ids = List.of("fireball", "great_sword", "frost_shard", "longbow", "quickstep", "second_wind",
@@ -44,7 +44,10 @@ class TrainingDummyNativeTest {
var chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(admin.world);
when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z); when(chunk.getEntities()).thenReturn(new Entity[0]);
when(admin.world.getChunkAt(x, z)).thenReturn(chunk); when(admin.world.isChunkLoaded(x, z)).thenReturn(true);
when(admin.world.getEntities()).thenReturn(List.of());
var pusher = zombie(admin.core.location.clone().add(1, 0, 0));
var projectile = mock(org.bukkit.entity.Arrow.class);
when(projectile.getUniqueId()).thenReturn(UUID.randomUUID());
when(admin.world.getEntities()).thenReturn(List.of(admin.player, pusher, projectile));
var zombie = zombie(admin.core.location);
doAnswer(call -> { ((Consumer<Zombie>) call.getArgument(2)).accept(zombie); return zombie; })
.when(admin.world).spawn(any(Location.class), eq(Zombie.class), any(Consumer.class));
@@ -59,12 +62,22 @@ class TrainingDummyNativeTest {
Runnable callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
var saved = directory.all().getFirst(); assertEquals("Trial Target", saved.name());
verify(zombie).setAI(false); verify(zombie).setGravity(false); verify(zombie).setCollidable(false);
verify(zombie).setAI(false); verify(zombie).setGravity(false); verify(zombie).setCollidable(true);
assertTrue(zombie.getCollidableExemptions().contains(admin.core.id));
assertTrue(zombie.getCollidableExemptions().contains(pusher.getUniqueId()));
assertFalse(zombie.getCollidableExemptions().contains(projectile.getUniqueId()),
"Projectile identities must retain collision with the dummy hit surface");
verify(zombie).setCanPickupItems(false); verify(zombie).setRemoveWhenFarAway(false); verify(zombie).setPersistent(true);
verify(zombie).setShouldBurnInDay(false); verify(zombie).setBaby(false);
verify(zombie).customName(Component.text("Trial Target")); verify(zombie).setCustomNameVisible(true);
var controller = (TrainingDummyController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummyController");
assertTrue(controller.tagged(zombie));
var latePusher = zombie(admin.core.location.clone().add(2, 0, 0));
when(admin.world.getEntities()).thenReturn(List.of(zombie, latePusher));
controller.entityAdded(new com.destroystokyo.paper.event.entity.EntityAddToWorldEvent(latePusher, admin.world));
assertTrue(zombie.getCollidableExemptions().contains(latePusher.getUniqueId()));
controller.entityRemoved(new com.destroystokyo.paper.event.entity.EntityRemoveFromWorldEvent(latePusher, admin.world));
assertFalse(zombie.getCollidableExemptions().contains(latePusher.getUniqueId()));
assertFalse(((games.dmg.fantasystick.party.PartyRegistrarController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partyRegistrarController")).tagged(zombie));
assertFalse(((games.dmg.fantasystick.shop.FantasyArmorerController)
@@ -222,11 +235,45 @@ class TrainingDummyNativeTest {
}
}
@Test void pinnedNativeProjectileSweepRequiresTheDummyPickableHitSurface() {
var level = mock(net.minecraft.world.level.Level.class);
var projectile = mock(net.minecraft.world.entity.Entity.class);
when(projectile.level()).thenReturn(level); when(projectile.getRootVehicle()).thenReturn(projectile);
var dummy = mock(net.minecraft.world.entity.monster.zombie.Zombie.class, CALLS_REAL_METHODS);
doReturn(false).when(dummy).isRemoved();
doReturn(new net.minecraft.world.phys.AABB(1, 0, 0, 2, 2, 1)).when(dummy).getBoundingBox();
doReturn(0f).when(dummy).getPickRadius(); doReturn(dummy).when(dummy).getRootVehicle();
when(level.getEntities(same(projectile), any(net.minecraft.world.phys.AABB.class), any())).thenAnswer(call -> {
@SuppressWarnings("unchecked")
var predicate = (java.util.function.Predicate<net.minecraft.world.entity.Entity>) call.getArgument(2);
return predicate.test(dummy) ? List.of(dummy) : List.of();
});
var start = new net.minecraft.world.phys.Vec3(0, 1, 0.5);
var end = new net.minecraft.world.phys.Vec3(3, 1, 0.5);
var search = new net.minecraft.world.phys.AABB(0, 0, 0, 3, 2, 1);
dummy.collides = false;
assertFalse(dummy.isPickable());
assertNull(net.minecraft.world.entity.projectile.ProjectileUtil.getEntityHitResult(
projectile, start, end, search, net.minecraft.world.entity.Entity::isPickable, 9));
dummy.collides = true;
assertTrue(dummy.isPickable());
assertSame(dummy, net.minecraft.world.entity.projectile.ProjectileUtil.getEntityHitResult(
projectile, start, end, search, net.minecraft.world.entity.Entity::isPickable, 9).getEntity());
var pusher = mock(net.minecraft.world.entity.Entity.class);
var pusherId = UUID.randomUUID(); when(pusher.getUUID()).thenReturn(pusherId);
doReturn(true).when(dummy).isPushable(); dummy.collidableExemptions = new java.util.HashSet<>();
assertTrue(dummy.canCollideWithBukkit(pusher));
dummy.collidableExemptions.add(pusherId);
assertFalse(dummy.canCollideWithBukkit(pusher),
"A living-entity exemption suppresses body pushing without removing projectile pickability");
}
static Zombie zombie(Location location) {
var zombie = mock(Zombie.class); when(zombie.getUniqueId()).thenReturn(UUID.randomUUID());
when(zombie.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); when(zombie.getName()).thenReturn("Training Dummy");
when(zombie.getLocation()).thenReturn(location.clone()); when(zombie.getHealth()).thenReturn(20d);
when(zombie.getAbsorptionAmount()).thenReturn(0d);
when(zombie.getLocation()).thenReturn(location.clone()); when(zombie.getWorld()).thenReturn(location.getWorld());
when(zombie.getHealth()).thenReturn(20d); when(zombie.getAbsorptionAmount()).thenReturn(0d);
when(zombie.getCollidableExemptions()).thenReturn(new java.util.HashSet<>());
Map<String, String> values = new HashMap<>(); var pdc = mock(PersistentDataContainer.class);
when(zombie.getPersistentDataContainer()).thenReturn(pdc);
doAnswer(call -> { values.put(((NamespacedKey) call.getArgument(0)).getKey(), call.getArgument(2)); return null; })
@@ -35,7 +35,7 @@ class WarBannerNativeTest {
@Test void bookAcquisitionSolidTopPlacementBenefitsPartyAndRewardsUsedDamage()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);use(f,caster);drain(f,2);assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);f.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(f);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no War Banner particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.DUST&&call[2].equals(16)),"An enabled nearby viewer retains War Banner visuals");assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
}
@@ -285,7 +285,8 @@ class WholeCharacterTransferNativeTest {
}
if (ending == Ending.DEFEAT) {
if (restoration != Restoration.SUCCESS) h.f.expectedFailure = IllegalStateException.class;
h.f.tick(); // Actual asynchronous death acknowledgment / one-use Tail attempt.
if (restoration == Restoration.SUCCESS)
h.f.tick(); // Restoration acknowledgment permits the one-use Tail attempt.
var dead = h.saved(record.id()); assertTrue(dead.dead()); assertProgress(renamed, dead);
assertEquals(0, dead.health().signum());
assertEquals(restoration == Restoration.SUCCESS ? Material.WRITTEN_BOOK : Material.PAPER,
@@ -29,16 +29,31 @@ class DistributionTest {
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
assertEquals(java.util.Set.of("fantasystick", "fantasyfeedback"), metadata.getCommands().keySet());
assertEquals(java.util.Set.of("fantasystick", "fantasyfeedback", "fantasyparticles"),
metadata.getCommands().keySet());
assertNull(metadata.getCommands().get("fantasystick").get("permission"),
"Player adventure inspection must reach command-level authorization");
assertNull(metadata.getCommands().get("fantasyfeedback").get("permission"));
assertNull(metadata.getCommands().get("fantasyparticles").get("permission"));
assertEquals("/fantasyfeedback [on|off]", metadata.getCommands().get("fantasyfeedback").get("usage"));
assertEquals("/fantasyparticles <all|players> [on|off]",
metadata.getCommands().get("fantasyparticles").get("usage"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("adventure-site"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("quest-giver"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString()
.contains("character view <online-player>"));
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
var directParticleEmitters = jar.stream().filter(entry -> entry.getName().endsWith(".class"))
.filter(entry -> {
try (var input = jar.getInputStream(entry)) {
return new String(input.readAllBytes(), java.nio.charset.StandardCharsets.ISO_8859_1)
.contains("spawnParticle");
} catch (java.io.IOException failure) { throw new java.io.UncheckedIOException(failure); }
}).map(java.util.zip.ZipEntry::getName).collect(java.util.stream.Collectors.toSet());
assertEquals(java.util.Set.of("games/dmg/fantasystick/presentation/ParticleDelivery.class"),
directParticleEmitters, "Every owned particle producer must route through viewer-aware delivery");
var configuration = jar.getJarEntry("config.yml");
assertNotNull(configuration, "Ship reviewed session defaults for administrators");
String defaults;
@@ -49,7 +64,7 @@ class DistributionTest {
"The delivered configuration must expose the reviewed 15-minute default");
assertTrue(defaults.contains("adventure-mode: true"));
for (String setting : java.util.List.of("adventure-daily-site-count: 3", "adventure-site-radius: 16",
"adventure-site-reset-seconds: 30", "adventure-camp-enemies: 8",
"adventure-site-reset-seconds: 30", "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")) {
@@ -0,0 +1,23 @@
package games.dmg.fantasystick.adventure;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class AdventureArenaTest {
@Test void circleUsesHorizontalRadiusWithExactEdgeInclusion() {
var arena = AdventureQuestState.Arena.circle(10);
assertTrue(arena.contains(0, 0));
assertTrue(arena.contains(10, 0));
assertTrue(arena.contains(-6, -8));
assertFalse(arena.contains(10, 0.001));
}
@Test void rectangleUsesCenteredHalfOfFullXZSpansWithExactEdges() {
var arena = AdventureQuestState.Arena.rectangle(20, 30);
assertTrue(arena.contains(10, 15));
assertTrue(arena.contains(-10, -15));
assertFalse(arena.contains(10.001, 0));
assertFalse(arena.contains(0, -15.001));
}
}
@@ -54,6 +54,8 @@ class AdventureQuestDirectoryTest {
var second = restarted.ensureToday().get(5, TimeUnit.SECONDS);
assertEquals(LocalDate.of(2026, 9, 22), second.day());
assertTrue(Collections.disjoint(first.sites(), second.sites()), "Enough alternatives exclude yesterday's sites");
assertNotNull(restarted.snapshot().reset());
assertTrue(restarted.completeReset(restarted.snapshot().reset().operation()).get(5, TimeUnit.SECONDS));
UUID removed = second.sites().getFirst();
var quest = restarted.accept(second.revision(), removed, AdventureQuestState.UnitType.SOLO,
member, Set.of(member)).get(5, TimeUnit.SECONDS).quest().orElseThrow();
@@ -63,6 +65,22 @@ class AdventureQuestDirectoryTest {
}
}
@Test void explicitArenaShapesPersistWhileLegacySitesKeepConfiguredFallback() throws Exception {
var rules=new AdventureQuestSettings.Rules(3,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
UUID circleId,rectangleId,legacyId;
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),Clock.systemUTC(),()->rules,new Random(1))){
circleId=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Circle",UUID.randomUUID(),1.25,64,-2.5,AdventureQuestState.Arena.circle(10)).get(5,TimeUnit.SECONDS).id();
rectangleId=directory.placeSite(AdventureQuestState.SiteType.CHAMPION,"Rectangle",UUID.randomUUID(),2,65,3,AdventureQuestState.Arena.rectangle(20,30)).get(5,TimeUnit.SECONDS).id();
legacyId=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Legacy",UUID.randomUUID(),4,66,5).get(5,TimeUnit.SECONDS).id();
}
try(var restarted=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),Clock.systemUTC(),()->rules,new Random(2))){
assertEquals(AdventureQuestState.Arena.circle(10),restarted.findSite(circleId).orElseThrow().arena());
assertEquals(AdventureQuestState.Arena.rectangle(20,30),restarted.findSite(rectangleId).orElseThrow().arena());
assertNull(restarted.findSite(legacyId).orElseThrow().arena());
assertEquals(AdventureQuestState.Arena.circle(16),restarted.findSite(legacyId).orElseThrow().arenaOrDefault(16));
}
}
@Test void championAcceptanceCapturesChampionPoolWithoutCreatingACampRoster() throws Exception {
var rules=new AdventureQuestSettings.Rules(3,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"),ZoneOffset.UTC),()->rules,new Random(1))){
@@ -74,39 +92,173 @@ class AdventureQuestDirectoryTest {
}
}
@Test void confirmedCurrentDayRerollPrefersAlternativesPreservesClaimsAndSurvivesRestart() throws Exception {
@Test void confirmedCurrentDayResetPrefersAlternativesClearsClaimsAndSurvivesRestart() throws Exception {
var clock=new MutableClock(Instant.parse("2026-09-22T00:20:00Z"));
var rules=new AdventureQuestSettings.Rules(2,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
UUID character=UUID.randomUUID(),claimed;AdventureQuestState.Catalog rerolled;
UUID character=UUID.randomUUID(),claimed;AdventureQuestState.Catalog resetCatalog;
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(4))){
for(int i=0;i<4;i++)directory.placeSite(AdventureQuestState.SiteType.CAMP,"Camp "+i,UUID.randomUUID(),i,64,0).get(5,TimeUnit.SECONDS);
var first=directory.ensureToday().get(5,TimeUnit.SECONDS);claimed=first.sites().getFirst();var quest=directory.accept(first.revision(),claimed,AdventureQuestState.UnitType.SOLO,character,Set.of(character)).get(5,TimeUnit.SECONDS).quest().orElseThrow();assertTrue(directory.complete(quest.id(),Set.of(character)).get(5,TimeUnit.SECONDS));directory.roster(first.sites().get(1)).get(5,TimeUnit.SECONDS);assertFalse(directory.snapshot().rosters().isEmpty());
var result=directory.rerollToday().get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.APPLIED,result.status());var alternate=result.catalog().orElseThrow();var stale=directory.rerollToday(first.revision()).get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.STALE,stale.status());assertEquals(alternate,directory.snapshot().catalog());assertEquals(first.day(),alternate.day());assertEquals(first.expiresAt(),alternate.expiresAt());assertNotEquals(first.revision(),alternate.revision());assertTrue(Collections.disjoint(first.sites(),alternate.sites()));assertTrue(directory.snapshot().rosters().isEmpty());assertTrue(directory.claimed(alternate.revision(),claimed,character,null));
rerolled=directory.rerollToday().get(5,TimeUnit.SECONDS).catalog().orElseThrow();assertTrue(rerolled.sites().contains(claimed));assertEquals(AdventureQuestDirectory.Acceptance.CLAIMED,directory.accept(rerolled.revision(),claimed,AdventureQuestState.UnitType.SOLO,character,Set.of(character)).get(5,TimeUnit.SECONDS).status());
var activeCharacter=UUID.randomUUID();var active=directory.accept(rerolled.revision(),rerolled.sites().stream().filter(site->!site.equals(claimed)).findFirst().orElseThrow(),AdventureQuestState.UnitType.SOLO,activeCharacter,Set.of(activeCharacter)).get(5,TimeUnit.SECONDS).quest().orElseThrow();var blocked=directory.rerollToday().get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.ACTIVE,blocked.status());assertEquals(rerolled,directory.snapshot().catalog());assertTrue(directory.findQuest(active.id()).isPresent());
var first=directory.ensureToday().get(5,TimeUnit.SECONDS);claimed=first.sites().getFirst();var quest=directory.accept(first.revision(),claimed,AdventureQuestState.UnitType.SOLO,character,Set.of(character)).get(5,TimeUnit.SECONDS).quest().orElseThrow();assertTrue(directory.complete(quest.id(),Set.of(character)).get(5,TimeUnit.SECONDS));directory.roster(first.sites().get(1)).get(5,TimeUnit.SECONDS);
var result=directory.rerollToday().get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.APPLIED,result.status());var alternate=result.catalog().orElseThrow();assertEquals(AdventureQuestDirectory.RerollStatus.STALE,directory.rerollToday(first.revision()).get(5,TimeUnit.SECONDS).status());assertEquals(first.expiresAt(),alternate.expiresAt());assertTrue(Collections.disjoint(first.sites(),alternate.sites()));assertFalse(directory.claimed(alternate.revision(),claimed,character,null));var firstReset=directory.snapshot().reset();assertNotNull(firstReset);assertTrue(directory.completeReset(firstReset.operation()).get(5,TimeUnit.SECONDS));
var activeCharacter=UUID.randomUUID();var active=directory.accept(alternate.revision(),alternate.sites().getFirst(),AdventureQuestState.UnitType.SOLO,activeCharacter,Set.of(activeCharacter)).get(5,TimeUnit.SECONDS).quest().orElseThrow();var activeReset=directory.rerollToday().get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.APPLIED,activeReset.status());assertTrue(directory.findQuest(active.id()).isEmpty());resetCatalog=activeReset.catalog().orElseThrow();var pending=directory.snapshot().reset();assertNotNull(pending);assertTrue(directory.completeReset(pending.operation()).get(5,TimeUnit.SECONDS));
}
try(var restarted=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(99))){assertEquals(resetCatalog,restarted.ensureToday().get(5,TimeUnit.SECONDS));assertNull(restarted.snapshot().reset());assertFalse(restarted.claimed(resetCatalog.revision(),claimed,character,null));}
}
@Test void confirmedResetExpiresActiveWorkClearsClaimsAndAllowsSameSiteRewardAgain() throws Exception {
var rules=new AdventureQuestSettings.Rules(1,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
var clock=Clock.fixed(Instant.parse("2026-09-22T04:00:00Z"),ZoneOffset.UTC);
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1))){
var site=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Only",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS);
var first=directory.ensureToday().get(5,TimeUnit.SECONDS);
UUID claimed=UUID.randomUUID(),active=UUID.randomUUID();
var claimedQuest=directory.accept(first.revision(),site.id(),AdventureQuestState.UnitType.SOLO,claimed,Set.of(claimed)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
assertTrue(directory.complete(claimedQuest.id(),Set.of(claimed)).get(5,TimeUnit.SECONDS));
directory.accept(first.revision(),site.id(),AdventureQuestState.UnitType.SOLO,active,Set.of(active)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
directory.roster(site.id()).get(5,TimeUnit.SECONDS);
var reset=directory.rerollToday(first.revision()).get(5,TimeUnit.SECONDS);
assertEquals(AdventureQuestDirectory.RerollStatus.APPLIED,reset.status());
var replacement=reset.catalog().orElseThrow();
assertEquals(first.expiresAt(),replacement.expiresAt());
assertTrue(directory.snapshot().quests().isEmpty());
assertTrue(directory.snapshot().unitClaims().isEmpty());
assertTrue(directory.snapshot().characterClaims().isEmpty());
assertTrue(directory.snapshot().rosters().isEmpty());
var pending=directory.snapshot().reset();
assertNotNull(pending);
assertEquals(replacement.revision(),pending.generation());
assertEquals(AdventureQuestDirectory.Acceptance.STALE,directory.accept(replacement.revision(),site.id(),AdventureQuestState.UnitType.SOLO,claimed,Set.of(claimed)).get(5,TimeUnit.SECONDS).status());
assertTrue(directory.completeReset(pending.operation()).get(5,TimeUnit.SECONDS));
assertNull(directory.snapshot().reset());
assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),site.id(),AdventureQuestState.UnitType.SOLO,claimed,Set.of(claimed)).get(5,TimeUnit.SECONDS).status());
}
}
@Test void failedResetPublicationRetainsDurablePendingOperationForRetry() throws Exception {
var rules=new AdventureQuestSettings.Rules(1,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
var repository=new YamlAdventureQuestRepository(data);
try(var directory=new AdventureQuestDirectory(repository,Clock.systemUTC(),()->rules,new Random(1))){
directory.placeSite(AdventureQuestState.SiteType.CAMP,"Camp",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS);
var first=directory.ensureToday().get(5,TimeUnit.SECONDS);
var replacement=directory.rerollToday(first.revision()).get(5,TimeUnit.SECONDS).catalog().orElseThrow();
var pending=directory.snapshot().reset();assertNotNull(pending);assertEquals(pending,new YamlAdventureQuestRepository(data).load().reset());
var file=data.resolve("adventure-quests.yml");byte[] durable=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.completeReset(pending.operation()).get(5,TimeUnit.SECONDS));assertEquals(pending,directory.snapshot().reset());assertEquals(replacement,directory.snapshot().catalog());
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,durable);
assertTrue(directory.completeReset(pending.operation()).get(5,TimeUnit.SECONDS));assertNull(directory.snapshot().reset());
}
try(var restarted=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(99))){assertEquals(rerolled,restarted.ensureToday().get(5,TimeUnit.SECONDS));assertTrue(restarted.claimed(rerolled.revision(),claimed,character,null));}
}
@Test void emptyRerollIsDurableAndFailedReplacementRetainsPriorCatalog() throws Exception {
var rules=new AdventureQuestSettings.Rules(3,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));var clock=Clock.fixed(Instant.parse("2026-09-22T00:30:00Z"),ZoneOffset.UTC);
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1))){var first=directory.ensureToday().get(5,TimeUnit.SECONDS);var empty=directory.rerollToday().get(5,TimeUnit.SECONDS).catalog().orElseThrow();assertTrue(empty.sites().isEmpty());assertNotEquals(first.revision(),empty.revision());var file=data.resolve("adventure-quests.yml");java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);java.nio.file.Files.writeString(file.resolve("block"),"retain");assertThrows(java.util.concurrent.ExecutionException.class,()->directory.rerollToday().get(5,TimeUnit.SECONDS));assertEquals(empty,directory.snapshot().catalog());}
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1))){var first=directory.ensureToday().get(5,TimeUnit.SECONDS);var empty=directory.rerollToday().get(5,TimeUnit.SECONDS).catalog().orElseThrow();assertTrue(empty.sites().isEmpty());assertNotEquals(first.revision(),empty.revision());assertTrue(directory.completeReset(directory.snapshot().reset().operation()).get(5,TimeUnit.SECONDS));var file=data.resolve("adventure-quests.yml");java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);java.nio.file.Files.writeString(file.resolve("block"),"retain");assertThrows(java.util.concurrent.ExecutionException.class,()->directory.rerollToday().get(5,TimeUnit.SECONDS));assertEquals(empty,directory.snapshot().catalog());}
}
@Test void failedAdventureWriteRetainsPublishedState() throws Exception {
var blocked=data.resolve("blocked");java.nio.file.Files.writeString(blocked,"not a directory");
var rules=AdventureQuestSettings.parse(java.util.Map.of(
AdventureQuestSettings.DAILY_COUNT,"3",AdventureQuestSettings.RADIUS,"16",
AdventureQuestSettings.RESET_SECONDS,"30",AdventureQuestSettings.CAMP_ENEMIES,"8",
AdventureQuestSettings.CAMP_REWARD_XP,"100",AdventureQuestSettings.CAMP_REWARD_COINS,"20",
AdventureQuestSettings.CHAMPION_HEALTH,"100",AdventureQuestSettings.CHAMPION_DAMAGE,"8",
AdventureQuestSettings.CHAMPION_REWARD_XP,"150",AdventureQuestSettings.CHAMPION_REWARD_COINS,"40"));
var rules=AdventureQuestSettings.parse(java.util.Map.ofEntries(
java.util.Map.entry(AdventureQuestSettings.DAILY_COUNT,"3"),java.util.Map.entry(AdventureQuestSettings.RADIUS,"16"),
java.util.Map.entry(AdventureQuestSettings.RESET_SECONDS,"30"),java.util.Map.entry(AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE,"10"),
java.util.Map.entry(AdventureQuestSettings.CAMP_ENEMIES,"8"),
java.util.Map.entry(AdventureQuestSettings.CAMP_REWARD_XP,"100"),java.util.Map.entry(AdventureQuestSettings.CAMP_REWARD_COINS,"20"),
java.util.Map.entry(AdventureQuestSettings.CHAMPION_HEALTH,"100"),java.util.Map.entry(AdventureQuestSettings.CHAMPION_DAMAGE,"8"),
java.util.Map.entry(AdventureQuestSettings.CHAMPION_REWARD_XP,"150"),java.util.Map.entry(AdventureQuestSettings.CHAMPION_REWARD_COINS,"40")));
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(blocked),Clock.systemUTC(),()->rules,new Random(1))){
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.placeSite(AdventureQuestState.SiteType.CAMP,"Camp",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS));
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.placeSite(AdventureQuestState.SiteType.CAMP,"Camp",UUID.randomUUID(),0,64,0,AdventureQuestState.Arena.rectangle(20,30)).get(5,TimeUnit.SECONDS));
assertTrue(directory.snapshot().sites().isEmpty());
}
}
@Test void enabledExternalDungeonCandidateJoinsAutomaticCatalog() throws Exception {
var clock=Clock.fixed(Instant.parse("2026-09-22T12:00:00Z"),ZoneOffset.UTC);var dungeon=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.DUNGEON,"Keep",UUID.randomUUID(),1,2,3);var rules=new AdventureQuestSettings.Rules(1,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1))){directory.registerDungeonSites(()->List.of(dungeon));directory.registerDungeonAvailability(ignored->true);var catalog=directory.ensureToday().get(5,TimeUnit.SECONDS);assertEquals(List.of(dungeon.id()),catalog.sites());assertEquals(dungeon,directory.findSite(dungeon.id()).orElseThrow());}
}
@Test void manualDungeonOffersAppendWithoutEvictionAndStopAtFixedCeiling() throws Exception {
var clock=Clock.fixed(Instant.parse("2026-09-22T12:00:00Z"),ZoneOffset.UTC);var world=UUID.randomUUID();var sites=new ArrayList<AdventureQuestState.Site>();for(int i=0;i<257;i++)sites.add(new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.DUNGEON,"Dungeon "+i,world,i,64,0));var catalog=new AdventureQuestState.Catalog(LocalDate.of(2026,9,22),UUID.randomUUID(),clock.instant(),sites.stream().limit(256).map(AdventureQuestState.Site::id).toList());new YamlAdventureQuestRepository(data).save(new AdventureQuestState(sites,List.of(),catalog,List.of(),Set.of(),Set.of(),Map.of()));var rules=new AdventureQuestSettings.Rules(0,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1))){var before=directory.snapshot().catalog();assertEquals(before,directory.appendOffer(before.sites().getFirst()).get(5,TimeUnit.SECONDS));assertThrows(java.util.concurrent.ExecutionException.class,()->directory.appendOffer(sites.getLast().id()).get(5,TimeUnit.SECONDS));assertEquals(256,directory.snapshot().catalog().sites().size());assertEquals(before.revision(),directory.snapshot().catalog().revision());}
}
@Test void perCharacterQuestTrackingDefaultsOnPersistsAndExpiresWithExactQuest() throws Exception {
var clock=Clock.fixed(Instant.parse("2026-09-22T12:00:00Z"),ZoneOffset.UTC);
var rules=new AdventureQuestSettings.Rules(1,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
UUID first=UUID.randomUUID(),second=UUID.randomUUID(),party=UUID.randomUUID();
AdventureQuestState.Quest quest;
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1))){
var site=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Tracked Camp",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS);
var catalog=directory.ensureToday().get(5,TimeUnit.SECONDS);
quest=directory.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.PARTY,party,Set.of(first,second)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
assertTrue(directory.trackingEnabled(quest.id(),first));
assertTrue(directory.trackingEnabled(quest.id(),second));
assertEquals(2,directory.snapshot().tracking().size(),"acceptance persists an independent default-on choice");
assertEquals(AdventureQuestDirectory.TrackingStatus.APPLIED,directory.setTracking(quest.id(),first,false).get(5,TimeUnit.SECONDS));
assertFalse(directory.trackingEnabled(quest.id(),first));
assertTrue(directory.trackingEnabled(quest.id(),second));
}
try(var restarted=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(2))){
assertFalse(restarted.trackingEnabled(quest.id(),first));
assertTrue(restarted.trackingEnabled(quest.id(),second));
assertEquals(AdventureQuestDirectory.TrackingStatus.UNCHANGED,restarted.setTracking(quest.id(),first,false).get(5,TimeUnit.SECONDS));
assertEquals(AdventureQuestDirectory.TrackingStatus.INELIGIBLE,restarted.setTracking(quest.id(),UUID.randomUUID(),true).get(5,TimeUnit.SECONDS));
var file=data.resolve("adventure-quests.yml");byte[] durable=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
assertThrows(java.util.concurrent.ExecutionException.class,()->restarted.setTracking(quest.id(),first,true).get(5,TimeUnit.SECONDS));
assertFalse(restarted.trackingEnabled(quest.id(),first),"failed persistence cannot publish a changed preference");
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,durable);
assertEquals(AdventureQuestDirectory.TrackingStatus.APPLIED,restarted.setTracking(quest.id(),first,true).get(5,TimeUnit.SECONDS));
assertTrue(restarted.abandon(quest.id(),party).get(5,TimeUnit.SECONDS));
assertTrue(restarted.snapshot().tracking().isEmpty(),"quest-ending mutation removes exact tracking receipts");
assertFalse(restarted.trackingEnabled(quest.id(),first));
}
}
@Test void rewardBankingAndRecoverableClaimsAreDurableIndependentAndGenerationBounded() throws Exception {
var clock=new MutableClock(Instant.parse("2026-09-22T12:00:00Z"));
var rules=new AdventureQuestSettings.Rules(2,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
UUID first=UUID.randomUUID(),second=UUID.randomUUID(),party=UUID.randomUUID();AdventureQuestState.Catalog catalog;AdventureQuestState.Quest quest;UUID site;
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1))){
site=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Banked Camp",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS).id();
directory.placeSite(AdventureQuestState.SiteType.CAMP,"Other Camp",UUID.randomUUID(),20,64,0).get(5,TimeUnit.SECONDS);
catalog=directory.ensureToday().get(5,TimeUnit.SECONDS);quest=directory.accept(catalog.revision(),site,AdventureQuestState.UnitType.PARTY,party,Set.of(first,second)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
assertTrue(directory.bank(quest.id(),UUID.randomUUID(),Map.of(first,new AdventureQuestDirectory.Reward(BigInteger.ONE,BigInteger.ONE))).get(5,TimeUnit.SECONDS));
var rewards=Map.of(first,new AdventureQuestDirectory.Reward(BigInteger.valueOf(50),BigInteger.valueOf(9)),second,new AdventureQuestDirectory.Reward(BigInteger.valueOf(49),BigInteger.TEN));
assertTrue(directory.bankAndComplete(quest.id(),quest.id(),rewards).get(5,TimeUnit.SECONDS));
assertTrue(directory.active().isEmpty());assertTrue(directory.snapshot().tracking().isEmpty());
assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(51),BigInteger.TEN),directory.unclaimed(catalog.revision(),site,first));
assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(49),BigInteger.TEN),directory.unclaimed(catalog.revision(),site,second));
}
try(var restarted=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(2))){
var operation=restarted.beginClaim(catalog.revision(),site,first).get(5,TimeUnit.SECONDS).orElseThrow();
assertEquals(BigInteger.valueOf(51),operation.xp());assertEquals(BigInteger.TEN,operation.coins());assertEquals(operation,new YamlAdventureQuestRepository(data).load().rewards().operations().getFirst());
assertEquals(operation,restarted.beginClaim(catalog.revision(),site,first).get(5,TimeUnit.SECONDS).orElseThrow(),"concurrent/retried clicks reuse one durable operation");
assertTrue(restarted.beginClaim(catalog.revision(),site,UUID.randomUUID()).get(5,TimeUnit.SECONDS).isEmpty());
var file=data.resolve("adventure-quests.yml");byte[] durable=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
assertThrows(java.util.concurrent.ExecutionException.class,()->restarted.finishClaim(operation.id()).get(5,TimeUnit.SECONDS));
assertEquals(operation,restarted.pendingClaim(first).orElseThrow());assertEquals(BigInteger.valueOf(51),restarted.unclaimed(catalog.revision(),site,first).xp());
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,durable);
assertTrue(restarted.finishClaim(operation.id()).get(5,TimeUnit.SECONDS));assertTrue(restarted.pendingClaim(first).isEmpty());assertEquals(BigInteger.ZERO,restarted.unclaimed(catalog.revision(),site,first).xp());assertTrue(restarted.rewardClaimed(catalog.revision(),site,first));
assertEquals(BigInteger.valueOf(49),restarted.unclaimed(catalog.revision(),site,second).xp(),"one member claim never changes another");
var otherSite=catalog.sites().stream().filter(value->!value.equals(site)).findFirst().orElseThrow();UUID solo=UUID.randomUUID();var partial=restarted.accept(catalog.revision(),otherSite,AdventureQuestState.UnitType.SOLO,solo,Set.of(solo)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
assertTrue(restarted.bank(partial.id(),UUID.randomUUID(),Map.of(solo,new AdventureQuestDirectory.Reward(BigInteger.ONE,BigInteger.ONE))).get(5,TimeUnit.SECONDS));
assertTrue(restarted.abandon(partial.id(),solo).get(5,TimeUnit.SECONDS));assertEquals(BigInteger.ONE,restarted.unclaimed(catalog.revision(),otherSite,solo).xp(),"ordinary quest ending retains earned allocations");
var reset=restarted.rerollToday(catalog.revision()).get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.APPLIED,reset.status());assertTrue(restarted.snapshot().rewards().allocations().isEmpty());assertTrue(restarted.snapshot().rewards().operations().isEmpty());assertTrue(restarted.snapshot().rewards().receipts().isEmpty());var replacement=reset.catalog().orElseThrow();assertTrue(restarted.completeReset(restarted.snapshot().reset().operation()).get(5,TimeUnit.SECONDS));
UUID zero=UUID.randomUUID();var zeroQuest=restarted.accept(replacement.revision(),replacement.sites().getFirst(),AdventureQuestState.UnitType.SOLO,zero,Set.of(zero)).get(5,TimeUnit.SECONDS).quest().orElseThrow();assertTrue(restarted.bankAndComplete(zeroQuest.id(),zeroQuest.id(),Map.of(zero,new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO))).get(5,TimeUnit.SECONDS));assertEquals(BigInteger.ZERO,restarted.unclaimed(replacement.revision(),zeroQuest.site(),zero).xp());assertTrue(restarted.characterClaimed(replacement.revision(),zeroQuest.site(),zero));assertFalse(restarted.rewardClaimed(replacement.revision(),zeroQuest.site(),zero));
UUID expiring=UUID.randomUUID();var expiringQuest=restarted.accept(replacement.revision(),replacement.sites().getLast(),AdventureQuestState.UnitType.SOLO,expiring,Set.of(expiring)).get(5,TimeUnit.SECONDS).quest().orElseThrow();assertTrue(restarted.bank(expiringQuest.id(),UUID.randomUUID(),Map.of(expiring,new AdventureQuestDirectory.Reward(BigInteger.TEN,BigInteger.ONE))).get(5,TimeUnit.SECONDS));clock.advance(Duration.ofDays(1));restarted.ensureToday().get(5,TimeUnit.SECONDS);assertTrue(restarted.snapshot().rewards().allocations().isEmpty(),"midnight expires unclaimed rewards");
}
}
@Test void characterAwardBeforeLedgerCompletionRecoversWithoutDoubleGrant() throws Exception {
var clock=Clock.fixed(Instant.parse("2026-09-22T12:00:00Z"),ZoneOffset.UTC);var rules=new AdventureQuestSettings.Rules(1,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
var progression=games.dmg.fantasystick.progression.ProgressionRules.defaults();var record=new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);var charactersRepository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data.resolve("characters"));charactersRepository.create(record);
AdventureQuestState.RewardClaim operation;byte[] durableAdventure;
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1));var characters=new games.dmg.fantasystick.persistence.CharacterStorage(charactersRepository,clock)){
var site=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Recovery Camp",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS);var catalog=directory.ensureToday().get(5,TimeUnit.SECONDS);var quest=directory.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.SOLO,record.id(),Set.of(record.id())).get(5,TimeUnit.SECONDS).quest().orElseThrow();assertTrue(directory.bankAndComplete(quest.id(),quest.id(),Map.of(record.id(),new AdventureQuestDirectory.Reward(BigInteger.TEN,BigInteger.valueOf(3)))).get(5,TimeUnit.SECONDS));operation=directory.beginClaim(catalog.revision(),site.id(),record.id()).get(5,TimeUnit.SECONDS).orElseThrow();durableAdventure=java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml"));
var award=characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).get(5,TimeUnit.SECONDS);assertEquals(games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED,award.status());
var file=data.resolve("adventure-quests.yml");java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);assertThrows(java.util.concurrent.ExecutionException.class,()->directory.finishClaim(operation.id()).get(5,TimeUnit.SECONDS));java.nio.file.Files.delete(file);java.nio.file.Files.write(file,durableAdventure);
}
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(2));var characters=new games.dmg.fantasystick.persistence.CharacterStorage(charactersRepository,clock)){
var saved=characters.find(record.id()).get(5,TimeUnit.SECONDS).orElseThrow();var duplicate=characters.awardCombatReward(saved.id(),saved.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).get(5,TimeUnit.SECONDS);assertEquals(games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.DUPLICATE,duplicate.status());assertTrue(directory.finishClaim(operation.id()).get(5,TimeUnit.SECONDS));var finalRecord=characters.find(record.id()).get(5,TimeUnit.SECONDS).orElseThrow();assertEquals(BigInteger.TEN,finalRecord.experience());assertEquals(record.coins().add(BigInteger.valueOf(3)),finalRecord.coins());
}
}
private static final class MutableClock extends Clock {
private Instant value; MutableClock(Instant value){this.value=value;} void advance(Duration amount){value=value.plus(amount);}
@Override public ZoneId getZone(){return ZoneOffset.UTC;} @Override public Clock withZone(ZoneId zone){return this;}
@@ -0,0 +1,36 @@
package games.dmg.fantasystick.dungeon;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.*;
import games.dmg.fantasystick.progression.Stat;
import java.math.*;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.bukkit.entity.EntityType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class CharacterBossCustodyTest {
@TempDir Path data;
@Test void assignmentPersistsExactCharacterAndFlooredSixStatMultiplierUntilRecovery() throws Exception {
var stats=new EnumMap<Stat,BigInteger>(Stat.class);int value=11;for(var stat:Stat.values())stats.put(stat,BigInteger.valueOf(value++));
var character=new CharacterRecord(UUID.randomUUID(),"Mira Vale",BigInteger.valueOf(27),stats,8,BigInteger.valueOf(700),BigInteger.valueOf(3),BigDecimal.valueOf(20),Set.of("fireball","second_wind"),List.of("fireball","second_wind","","","","",""),List.of("speed_i","",""),Map.of("fireball",Instant.parse("2026-09-22T04:00:00Z")),Map.of(),false,null,null,BigInteger.valueOf(44),true,Set.of(),BigInteger.ZERO,new CharacterGear(GearTier.IRON,GearTier.GOLD,GearTier.DIAMOND));
UUID dungeonId,roomId,operation;
try(var directory=new DungeonDirectory(new YamlDungeonRepository(data))){
var dungeon=directory.create("Custody",UUID.randomUUID(),0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeonId=dungeon.id();
var room=directory.addRadiusRoom(dungeon.id(),"Boss",0,64,0,2,3).get(5,TimeUnit.SECONDS);roomId=room.id();
directory.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);
for(var invalid:List.of("0.099","100.001","1.0001"))assertThrows(IllegalArgumentException.class,()->directory.assignCharacterBoss(dungeon.id(),room.id(),character,new BigDecimal(invalid)));
var assigned=directory.assignCharacterBoss(dungeon.id(),room.id(),character,new BigDecimal("1.125")).get(5,TimeUnit.SECONDS);operation=assigned.operation();
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.assignCharacterBoss(dungeon.id(),room.id(),character,BigDecimal.ONE).get(5,TimeUnit.SECONDS));
assertEquals(character,assigned.character());assertEquals(EntityType.VINDICATOR,directory.room(dungeon.id(),room.id()).orElseThrow().boss().type());assertEquals(character.name(),directory.room(dungeon.id(),room.id()).orElseThrow().boss().name());
for(var stat:Stat.values())assertEquals(new BigDecimal(character.stats().get(stat)).multiply(new BigDecimal("1.125")).setScale(0,RoundingMode.FLOOR).toBigIntegerExact(),assigned.effectiveStats().get(stat));
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.clearBoss(dungeon.id(),room.id()).get(5,TimeUnit.SECONDS));assertThrows(java.util.concurrent.ExecutionException.class,()->directory.remove(dungeon.id()).get(5,TimeUnit.SECONDS));
}
try(var reloaded=new DungeonDirectory(new YamlDungeonRepository(data))){var custody=reloaded.custody(character.id()).orElseThrow();assertEquals(operation,custody.operation());assertEquals(character,custody.character());var generation=UUID.randomUUID();reloaded.run(dungeonId,generation).get(5,TimeUnit.SECONDS);reloaded.reset(new games.dmg.fantasystick.adventure.AdventureQuestState.Reset(UUID.randomUUID(),UUID.randomUUID(),Instant.parse("2026-09-22T05:00:00Z"))).toCompletableFuture().get(5,TimeUnit.SECONDS);assertTrue(reloaded.snapshot().runs().isEmpty());assertEquals(character,reloaded.custody(character.id()).orElseThrow().character());var recovery=reloaded.prepareRecovery(dungeonId,UUID.randomUUID()).get(5,TimeUnit.SECONDS);assertEquals(character,recovery.character());assertTrue(reloaded.completeRecovery(dungeonId,recovery.recovery().operation()).get(5,TimeUnit.SECONDS));assertTrue(reloaded.custody(character.id()).isEmpty());assertNull(reloaded.room(dungeonId,roomId).orElseThrow().boss());}
}
}
@@ -0,0 +1,90 @@
package games.dmg.fantasystick.dungeon;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.YamlDungeonRepository;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.entity.EntityType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class DungeonDirectoryTest {
@TempDir Path data;
@Test void persistsConnectedDefinitionAndRejectsOverlapOrDisconnectedRemoval() throws Exception {
var repository=new YamlDungeonRepository(data);
UUID world=UUID.randomUUID();
UUID dungeonId;
try(var directory=new DungeonDirectory(repository,()->1)){
var dungeon=directory.create("Sunken Keep",world,10.25,65,-4.75,90,0).get(5,TimeUnit.SECONDS);
dungeonId=dungeon.id();assertFalse(dungeon.enabled());assertEquals("Sunken Keep",dungeon.name());
var first=directory.addCornerRoom(dungeon.id(),"Entry",10,64,-5,4,4,3).get(5,TimeUnit.SECONDS);
var second=directory.addCornerRoom(dungeon.id(),"Hall",14,64,-5,3,4,3).get(5,TimeUnit.SECONDS);
assertTrue(first.bounds().contains(10,64,-5));assertTrue(second.bounds().touchesFace(first.bounds()));
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.addRadiusRoom(dungeon.id(),"Overlap",12,64,-5,2,3).get(5,TimeUnit.SECONDS));
var third=directory.addCornerRoom(dungeon.id(),"Boss",17,64,-5,2,4,3).get(5,TimeUnit.SECONDS);
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.removeRoom(dungeon.id(),second.id()).get(5,TimeUnit.SECONDS));
var mob=directory.addMob(dungeon.id(),first.id(),EntityType.HUSK,"Guard",10.5,65,-4.5,0,0).get(5,TimeUnit.SECONDS);
var effect=directory.addEffect(dungeon.id(),first.id(),DungeonState.EffectTarget.BOTH,"minecraft:speed",2,false).get(5,TimeUnit.SECONDS);
directory.setReward(dungeon.id(),first.id(),BigInteger.valueOf(50),BigInteger.TEN).get(5,TimeUnit.SECONDS);
directory.setBoss(dungeon.id(),third.id(),EntityType.VINDICATOR,"Keeper",17.5,65,-4.5,180,0).get(5,TimeUnit.SECONDS);
assertNotNull(mob.id());assertNotNull(effect.id());
assertTrue(directory.enable(dungeon.id(),java.util.List.of()).get(5,TimeUnit.SECONDS).enabled());
var overlap=directory.create("Overlap Draft",world,10,65,-5,0,0).get(5,TimeUnit.SECONDS);var overlapRoom=directory.addRadiusRoom(overlap.id(),"Boss",12,64,-5,1,3).get(5,TimeUnit.SECONDS);directory.setBoss(overlap.id(),overlapRoom.id(),EntityType.HUSK,"",12,65,-5,0,0).get(5,TimeUnit.SECONDS);assertThrows(java.util.concurrent.ExecutionException.class,()->directory.enable(overlap.id(),java.util.List.of()).get(5,TimeUnit.SECONDS));assertFalse(directory.find(overlap.id()).orElseThrow().enabled());
}
var reloaded=repository.load().dungeons().stream().filter(value->value.id().equals(dungeonId)).findFirst().orElseThrow();
assertTrue(reloaded.enabled());assertEquals(3,reloaded.rooms().size());assertEquals(BigInteger.valueOf(50),reloaded.maximumXp());assertEquals(BigInteger.TEN,reloaded.maximumCoins());
assertEquals(EntityType.VINDICATOR,reloaded.rooms().stream().filter(room->room.boss()!=null).findFirst().orElseThrow().boss().type());
}
@Test void configuredDistanceLinksHorizontalVerticalDiagonalAndTransitiveRooms() throws Exception {
var distance=new java.util.concurrent.atomic.AtomicInteger(10);
try(var directory=new DungeonDirectory(new YamlDungeonRepository(data),distance::get)){
var dungeon=directory.create("Linked",UUID.randomUUID(),0,1,0,0,0).get(5,TimeUnit.SECONDS);
var first=directory.addCornerRoom(dungeon.id(),"First",0,0,0,1,1,1).get(5,TimeUnit.SECONDS);
var horizontal=directory.addCornerRoom(dungeon.id(),"Horizontal",10,0,0,1,1,1).get(5,TimeUnit.SECONDS);
var transitive=directory.addCornerRoom(dungeon.id(),"Transitive",20,0,0,1,1,1).get(5,TimeUnit.SECONDS);
var vertical=directory.addCornerRoom(dungeon.id(),"Vertical",20,10,0,1,1,1).get(5,TimeUnit.SECONDS);
var diagonal=directory.addCornerRoom(dungeon.id(),"Diagonal",26,18,0,1,1,1).get(5,TimeUnit.SECONDS);
assertEquals(100d,first.bounds().distanceSquared(horizontal.bounds()));
assertEquals(100d,vertical.bounds().distanceSquared(diagonal.bounds()));
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.addCornerRoom(dungeon.id(),"Too Far",37,18,0,1,1,1).get(5,TimeUnit.SECONDS));
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.addCornerRoom(dungeon.id(),"Overlap",0,0,0,1,1,1).get(5,TimeUnit.SECONDS));
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.removeRoom(dungeon.id(),horizontal.id()).get(5,TimeUnit.SECONDS));
distance.set(4);assertThrows(java.util.concurrent.ExecutionException.class,()->directory.addCornerRoom(dungeon.id(),"Partial Repair",4,0,0,1,1,1).get(5,TimeUnit.SECONDS),"An addition must leave the complete graph linked under the current setting");distance.set(10);
directory.setBoss(dungeon.id(),first.id(),EntityType.HUSK,"Boss",0,0,0,0,0).get(5,TimeUnit.SECONDS);
assertTrue(directory.enable(dungeon.id(),java.util.List.of()).get(5,TimeUnit.SECONDS).enabled());var generation=UUID.randomUUID();directory.run(dungeon.id(),generation).get(5,TimeUnit.SECONDS);
distance.set(4);
assertFalse(directory.eligibleForFutureOffer(dungeon.id()));assertTrue(directory.runView(dungeon.id(),generation).isPresent(),"Current-generation work remains authoritative");
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.enable(dungeon.id(),java.util.List.of()).get(5,TimeUnit.SECONDS));
assertTrue(directory.find(dungeon.id()).orElseThrow().enabled(),"Reduced configuration does not rewrite retained state");
}
}
@Test void signedCornerAndCenteredRadiusUseExactInclusiveBlockBounds() throws Exception {
try(var directory=new DungeonDirectory(new YamlDungeonRepository(data))){var dungeon=directory.create("Geometry",UUID.randomUUID(),10,65,-5,0,0).get(5,TimeUnit.SECONDS);var negative=directory.addCornerRoom(dungeon.id(),"Negative",10,64,-5,-4,-3,2).get(5,TimeUnit.SECONDS);assertEquals(new DungeonState.Bounds(7,64,-7,10,65,-5),negative.bounds());var radius=directory.addRadiusRoom(dungeon.id(),"Radius",5,64,-6,1,2).get(5,TimeUnit.SECONDS);assertEquals(new DungeonState.Bounds(4,64,-7,6,65,-5),radius.bounds());assertTrue(radius.bounds().touchesFace(negative.bounds()));}
}
@Test void dailyProgressResetAndFailedDefinitionSavePreserveAuthoritativeState() throws Exception {
var repository=new YamlDungeonRepository(data);UUID world=UUID.randomUUID(),generation=UUID.randomUUID();
try(var directory=new DungeonDirectory(repository)){
var dungeon=directory.create("Vault",world,0,65,0,0,0).get(5,TimeUnit.SECONDS);
var room=directory.addRadiusRoom(dungeon.id(),"Core",0,64,0,2,3).get(5,TimeUnit.SECONDS);
var slot=directory.addMob(dungeon.id(),room.id(),EntityType.HUSK,"",0,65,0,0,0).get(5,TimeUnit.SECONDS);
directory.setBoss(dungeon.id(),room.id(),EntityType.VINDICATOR,"Boss",0,65,0,0,0).get(5,TimeUnit.SECONDS);
directory.enable(dungeon.id(),java.util.List.of()).get(5,TimeUnit.SECONDS);
directory.defeatSlot(dungeon.id(),generation,slot.id()).get(5,TimeUnit.SECONDS);
directory.clearRoom(dungeon.id(),generation,room.id(),UUID.randomUUID(),java.util.Set.of(UUID.randomUUID())).get(5,TimeUnit.SECONDS);
assertFalse(directory.snapshot().runs().isEmpty());
directory.reset(new games.dmg.fantasystick.adventure.AdventureQuestState.Reset(UUID.randomUUID(),UUID.randomUUID(),java.time.Instant.now())).toCompletableFuture().get(5,TimeUnit.SECONDS);
assertTrue(directory.snapshot().runs().isEmpty());assertEquals(1,directory.snapshot().dungeons().size());assertTrue(directory.snapshot().dungeons().getFirst().enabled());
}
var before=repository.load();var file=data.resolve("dungeons.yml");java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
assertThrows(java.io.IOException.class,()->new DungeonDirectory(new YamlDungeonRepository(data)));
java.nio.file.Files.delete(file);repository.save(before);
try(var directory=new DungeonDirectory(repository)){var existing=directory.snapshot();java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);assertThrows(java.util.concurrent.ExecutionException.class,()->directory.create("Unsaved",world,1,2,3,0,0).get(5,TimeUnit.SECONDS));assertEquals(existing,directory.snapshot());}
}
}
@@ -17,6 +17,7 @@ class AdventureQuestSettingsTest {
for (String key : AdventureQuestSettings.keys()) initial.put(key, AdventureQuestSettings.defaultValue(key));
var rules = AdventureQuestSettings.parse(initial);
assertEquals(3, rules.dailySiteCount()); assertEquals(16, rules.radius()); assertEquals(30, rules.resetSeconds());
assertEquals(10, rules.dungeonRoomLinkDistance());
assertEquals(8, rules.campEnemies()); assertEquals(java.math.BigInteger.valueOf(100), rules.campRewardXp());
assertEquals(java.math.BigInteger.valueOf(20), rules.campRewardCoins());
assertEquals(100, rules.championHealth()); assertEquals(8, rules.championDamage());
@@ -27,11 +28,14 @@ class AdventureQuestSettingsTest {
var update = settings.set(AdventureQuestSettings.CAMP_ENEMIES, "64").toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals("8", settings.value(AdventureQuestSettings.CAMP_ENEMIES));
assertTrue(settings.publish(update)); assertEquals("64", settings.value(AdventureQuestSettings.CAMP_ENEMIES));
assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: retained"));
var saved=Files.readString(data.resolve("config.yml"));assertTrue(saved.contains("unrelated: retained"));
assertFalse(saved.contains(AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE),"Unchanged packaged defaults do not rewrite explicit configuration");
}
assertInvalid(initial, AdventureQuestSettings.DAILY_COUNT, "21");
assertInvalid(initial, AdventureQuestSettings.RADIUS, "3");
assertInvalid(initial, AdventureQuestSettings.RESET_SECONDS, "601");
assertInvalid(initial, AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE, "0");
assertInvalid(initial, AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE, "257");
assertInvalid(initial, AdventureQuestSettings.CAMP_ENEMIES, "3");
assertInvalid(initial, AdventureQuestSettings.CAMP_REWARD_XP, "-1");
assertInvalid(initial, AdventureQuestSettings.CAMP_REWARD_COINS, "2147483648");
@@ -135,6 +135,54 @@ class CharacterStorageTest {
}
}
@Test
void restrictedSpacingAtomicallyDiscardsCoinsWithoutDisablingOtherZeroXpCoinAwards() throws Exception {
var repository = new YamlCharacterRepository(data);
var progression = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.TEN, 30);
var rewards = games.dmg.fantasystick.progression.CombatRewardRules.defaults();
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
var live = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(), base.passives(),
base.cooldowns(), base.restrictedKills(), false, null, null, BigInteger.valueOf(20), true, java.util.Set.of());
var world = UUID.randomUUID();
var first = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
var near = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
java.math.BigDecimal.ONE, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
var eligible = storage.awardCombatReward(live.id(), live.revision(), UUID.randomUUID(), BigInteger.TEN,
BigInteger.valueOf(6), progression, rewards, first).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, eligible.status());
assertEquals(BigInteger.valueOf(26), eligible.record().orElseThrow().coins());
var blockedId = UUID.randomUUID();
var blocked = storage.awardCombatReward(live.id(), eligible.record().orElseThrow().revision(), blockedId,
BigInteger.TEN, BigInteger.valueOf(7), progression, rewards, near).join();
assertEquals(CharacterStorage.CombatAwardStatus.SPACING_BLOCKED, blocked.status());
var afterBlocked = blocked.record().orElseThrow();
assertEquals(eligible.record().orElseThrow().experience(), afterBlocked.experience());
assertEquals(BigInteger.valueOf(26), afterBlocked.coins(), "Spacing discards rather than escrows the allocated coin share");
assertEquals(near, afterBlocked.restrictedKills().get(world), "Blocked awards advance the persisted spacing chain");
assertTrue(afterBlocked.rewardReceipts().contains(blockedId));
assertEquals(afterBlocked, storage.awardCombatReward(live.id(), afterBlocked.revision(), blockedId,
BigInteger.TEN, BigInteger.valueOf(7), progression, rewards, near).join().record().orElseThrow());
var zeroXp = storage.awardCombatReward(live.id(), afterBlocked.revision(), UUID.randomUUID(), BigInteger.ZERO,
BigInteger.valueOf(4), progression, rewards, null).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, zeroXp.status());
assertEquals(BigInteger.valueOf(30), zeroXp.record().orElseThrow().coins(),
"A non-spacing zero-XP result remains coin-eligible");
var cappedRules = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.TEN, 1);
var capped = storage.awardCombatReward(live.id(), zeroXp.record().orElseThrow().revision(), UUID.randomUUID(),
BigInteger.TEN, BigInteger.valueOf(5), cappedRules, rewards, null).join();
assertEquals(CharacterStorage.CombatAwardStatus.AT_CAP, capped.status());
assertEquals(BigInteger.valueOf(35), capped.record().orElseThrow().coins(),
"Maximum-level status alone must not suppress coins");
}
}
@Test
void stableCoinDebitIsAtomicIdempotentAndNeverOverdraws() throws Exception {
var repository = new YamlCharacterRepository(data);
@@ -46,14 +46,77 @@ class PlayerFeedbackPreferencesTest {
try (var reopened = new PlayerFeedbackPreferences(data)) { assertTrue(reopened.enabled(account)); }
}
@Test void auraDefaultsOffAndRemainsIndependentUnderAllParticlePrecedenceAcrossRestart() throws Exception {
UUID account = UUID.randomUUID();
java.nio.file.Files.writeString(data.resolve("player-preferences.yml"),
"unrelated: keep\nplayers:\n " + account + ":\n combat-feedback: false\n other: value\n");
try (var preferences = new PlayerFeedbackPreferences(data)) {
assertFalse(preferences.playerAurasEnabled(account));
assertTrue(preferences.allParticlesEnabled(account));
assertFalse(preferences.auraParticlesEnabled(account));
var auraOn = preferences.setPlayerAuras(account, true).toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertFalse(preferences.playerAurasEnabled(account), "Durability precedes effective publication");
assertTrue(preferences.publish(auraOn));
assertTrue(preferences.auraParticlesEnabled(account));
var allOff = preferences.setAllParticles(account, false).toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertTrue(preferences.playerAurasEnabled(account), "Changing all preserves the saved aura value");
assertTrue(preferences.publish(allOff));
assertFalse(preferences.auraParticlesEnabled(account));
}
String saved = java.nio.file.Files.readString(data.resolve("player-preferences.yml"));
assertTrue(saved.contains("unrelated: keep")); assertTrue(saved.contains("other: value"));
try (var reopened = new PlayerFeedbackPreferences(data)) {
assertTrue(reopened.playerAurasEnabled(account));
assertFalse(reopened.allParticlesEnabled(account));
assertFalse(reopened.auraParticlesEnabled(account));
assertFalse(reopened.enabled(account), "Combat feedback remains independent");
var allOn = reopened.setAllParticles(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(reopened.publish(allOn));
assertTrue(reopened.auraParticlesEnabled(account));
}
}
@Test void competingAuraWritesRejectStalePublicationAndAcceptedShutdownWorkDrains() throws Exception {
UUID account = UUID.randomUUID();
var preferences = new PlayerFeedbackPreferences(data);
var enabled = preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS);
var disabled = preferences.setPlayerAuras(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(preferences.publish(disabled));
assertFalse(preferences.publish(enabled));
assertFalse(preferences.playerAurasEnabled(account));
var accepted = preferences.setPlayerAuras(account, true).toCompletableFuture();
var acceptedAll = preferences.setAllParticles(account, false).toCompletableFuture();
preferences.close();
preferences.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertNotNull(accepted.get(5, TimeUnit.SECONDS));
assertNotNull(acceptedAll.get(5, TimeUnit.SECONDS));
assertFalse(preferences.playerAurasEnabled(account), "A closed lifetime does not publish its callback");
assertTrue(preferences.allParticlesEnabled(account), "A closed lifetime retains its effective all-particle state");
try (var reopened = new PlayerFeedbackPreferences(data)) {
assertTrue(reopened.playerAurasEnabled(account), "Accepted shutdown work is durable on restart");
assertFalse(reopened.allParticlesEnabled(account), "Accepted all-particle work is durable on restart");
}
}
@Test void failedOrClosedWritesRetainTheEffectivePreference() throws Exception {
UUID account = UUID.randomUUID();
var preferences = new PlayerFeedbackPreferences(data);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertThrows(Exception.class, () -> preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(Exception.class, () -> preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(Exception.class, () -> preferences.setAllParticles(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertTrue(preferences.enabled(account));
assertFalse(preferences.playerAurasEnabled(account));
assertTrue(preferences.allParticlesEnabled(account));
preferences.close();
assertThrows(Exception.class, () -> preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(Exception.class, () -> preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertThrows(Exception.class, () -> preferences.setAllParticles(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertTrue(preferences.enabled(account));
assertFalse(preferences.playerAurasEnabled(account));
assertTrue(preferences.allParticlesEnabled(account));
}
}