Compare commits

...
14 Commits
Author SHA1 Message Date
dmg af354a3097 feat(dungeon): add character-sheet bosses
Release / release (push) Successful in 11m47s
2026-09-22 08:57:38 -04:00
dmg e111d3919e feat(dungeon): add shared daily dungeons
Release / release (push) Failing after 10m1s
2026-09-21 23:34:39 -04:00
dmg 35418dbef5 feat(adventure): reset current adventure day
Release / release (push) Failing after 8m38s
2026-09-21 22:25:57 -04:00
dmg 3a2c4b8451 feat(adventure): add shaped site arenas
Release / release (push) Successful in 9m40s
2026-09-21 21:59:45 -04:00
dmg 60a29221f7 feat(adventure): present readable quest locations
Release / release (push) Successful in 9m23s
2026-09-21 21:40:05 -04:00
dmg 23179c62ae feat(feedback): render routine outcomes in gray
Release / release (push) Successful in 9m9s
2026-09-21 21:29:12 -04:00
dmg f0dc304f60 feat(adventure): add confirmed catalog rerolls
Release / release (push) Successful in 9m17s
2026-09-21 20:33:16 -04:00
dmg 3444c786c3 feat(adventure): add daily champion quests
Release / release (push) Successful in 9m59s
2026-09-21 19:56:35 -04:00
dmg 3ba3ef4a0b feat(adventure): add daily monster-camp quests
Release / release (push) Successful in 9m9s
2026-09-21 19:34:44 -04:00
dmg bc1a0b1f29 feat(passives): add basic-stick Knockback
Release / release (push) Successful in 8m54s
2026-09-21 18:34:13 -04:00
dmg fa219d9313 feat(rewards): add Witch hard-mob XP
Release / release (push) Successful in 8m30s
2026-09-21 18:21:10 -04:00
dmg d98a2a80a3 feat(combat): add base damage to basic attack
Release / release (push) Successful in 9m29s
2026-09-21 18:06:37 -04:00
dmg 7a85eea769 feat(book): add passive return navigation
Release / release (push) Successful in 9m24s
2026-09-21 17:19:47 -04:00
dmg 6d0d695052 feat(book): categorize active abilities
Release / release (push) Successful in 9m53s
2026-09-21 17:09:03 -04:00
80 changed files with 2882 additions and 140 deletions

No files matched your search

@@ -119,6 +119,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
private games.dmg.fantasystick.adventure.AdventureQuestDirectory adventureQuests;
private games.dmg.fantasystick.adventure.AdventureQuestController adventureQuestController;
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
private games.dmg.fantasystick.dungeon.DungeonDirectory dungeons;
private games.dmg.fantasystick.dungeon.DungeonController dungeonController;
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
@@ -170,6 +178,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
coinConfiguration.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.CoinSettings.defaultValue(key)));
}
var adventureQuestConfiguration = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.AdventureQuestSettings.keys()) {
adventureQuestConfiguration.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.AdventureQuestSettings.parse(adventureQuestConfiguration);
var recoveryConfiguration = outOfCombatRecoveryConfiguration();
var recoveryInitial = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(recoveryConfiguration);
var quickerRegenerationInitial = games.dmg.fantasystick.persistence.QuickerRegenerationSettings.parse(
@@ -186,7 +200,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY), sheetPackDefaults);
var rules = progressionRules();
var rewardRules = combatRewardRules();
var hardMobXpConfiguration = getConfig().getString(
games.dmg.fantasystick.persistence.HardMobXpSettings.KEY,
games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT);
var hardMobXp = games.dmg.fantasystick.persistence.HardMobXpSettings.parse(hardMobXpConfiguration);
var rewardRules = combatRewardRules(hardMobXp);
var fireballConfiguration = fireballConfiguration();
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
var greatSwordSettings = greatSwordSettings();
@@ -230,6 +248,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
getDataFolder().toPath(), partyEconomyConfiguration);
gearSettings=new games.dmg.fantasystick.persistence.GearSettings(getDataFolder().toPath(),gearConfiguration);
coinSettings = new games.dmg.fantasystick.persistence.CoinSettings(getDataFolder().toPath(), coinConfiguration);
hardMobXpSettings = new games.dmg.fantasystick.persistence.HardMobXpSettings(
getDataFolder().toPath(), hardMobXp.toString());
adventureQuestSettings = new games.dmg.fantasystick.persistence.AdventureQuestSettings(
getDataFolder().toPath(), adventureQuestConfiguration);
var factory = new CharacterFactory(coinSettings::rules);
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC(),
coinSettings::rules, java.util.random.RandomGenerator.getDefault());
@@ -290,9 +312,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
new games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository(getDataFolder().toPath()));
trainingDummies = new games.dmg.fantasystick.training.TrainingDummyDirectory(
new games.dmg.fantasystick.persistence.YamlTrainingDummyRepository(getDataFolder().toPath()));
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()));
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
} catch (java.io.IOException invalidManagedState) {
throw new java.io.UncheckedIOException("Persistent party, shop or training state could not be loaded", invalidManagedState);
throw new java.io.UncheckedIOException("Persistent party, shop, training or adventure state could not be loaded", invalidManagedState);
}
characterParties.suspend();
sheetResourcePack = new games.dmg.fantasystick.presentation.SheetResourcePack(sheetResourcePackSettings,
@@ -458,6 +485,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
passiveRegistry.register(healthSteal.definition());
passiveRegistry.register(speedI.definition());
passiveRegistry.register(lowLightVision.definition());
passiveRegistry.register(new games.dmg.fantasystick.passive.StickKnockback().definition());
passiveEngine = new games.dmg.fantasystick.passive.PassiveEngine(passiveRegistry, characters, sessions, nextTick,
problem -> getLogger().log(Level.WARNING, "Passive change failed for character " + problem.character()
+ "; passive " + problem.passive(), problem.cause()));
@@ -479,7 +507,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityScheduler::later, (size, title) -> getServer().createInventory(null, size, title),
problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem));
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
Clock.systemUTC(), nextTick, abilityScheduler, report -> { }, combatFeedback, trainingDummyController,
Clock.systemUTC(), nextTick, abilityScheduler, report -> {var current=dungeonController;if(current!=null)current.accept(report);}, combatFeedback, trainingDummyController,
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
+ "; ability " + problem.ability(), problem.cause()));
abilityEngine.install(this);
@@ -515,8 +543,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
if(lifetime==generation)localArmorMenus.refreshOpen();});}
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Fantasy Armorer menu refresh failed",failure);}});
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
adventureQuestController = new games.dmg.fantasystick.adventure.AdventureQuestController(this,
adventureQuests, adventureQuestSettings, sessions, parties, characters, rules, nextTick,
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault());
adventureQuestCommands = new games.dmg.fantasystick.adventure.AdventureQuestCommands(
this, adventureQuests, adventureQuestController, nextTick);
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());
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(games.dmg.fantasystick.dungeon.DungeonState.Dungeon::enabled).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);
combatExperience.attachExcluded(entity->adventureQuestController.managed(entity)||dungeonController.managed(entity));
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|| armorerController.tagged(entity));
|| armorerController.tagged(entity) || adventureQuestController.giver(entity));
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()), adventureSettings,
@@ -549,6 +594,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachArmorers(armorers,armorerController);
admin.attachArmorMarket(armorMarket);
admin.attachCoinSettings(coinSettings);
admin.attachHardMobXpSettings(hardMobXpSettings, combatExperience);
admin.attachAdventureQuests(adventureQuestCommands, adventureQuestSettings);
admin.attachDungeons(dungeonCommands);
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
@@ -577,9 +625,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
getServer().getPluginManager().registerEvents(armorerController,this);
getServer().getPluginManager().registerEvents(armorShopMenus,this);
getServer().getPluginManager().registerEvents(adventureQuestController,this);
getServer().getPluginManager().registerEvents(dungeonController,this);
partyRegistrarController.install();
trainingDummyController.install();
armorerController.install();
dungeonController.install();
adventureQuestController.install();
var localArmorMarket=armorMarket;
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
localArmorMarket.current().whenComplete((catalog,failure)->{
@@ -655,6 +707,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPartyRegistrarController = partyRegistrarController; partyRegistrarController = null;
var closingTrainingDummies = trainingDummies; trainingDummies = null;
var closingTrainingDummyController = trainingDummyController; trainingDummyController = null;
var closingAdventureQuests = adventureQuests; adventureQuests = null;
var closingAdventureQuestController = adventureQuestController; adventureQuestController = null;
adventureQuestCommands = null;
var closingDungeons=dungeons;dungeons=null;var closingDungeonController=dungeonController;dungeonController=null;dungeonCommands=null;
var closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
var closingPartyManagement = partyManagement; partyManagement = null;
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
@@ -716,6 +772,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPartyEconomySettings = partyEconomySettings; partyEconomySettings = null;
var closingGearSettings=gearSettings;gearSettings=null;
var closingCoinSettings = coinSettings; coinSettings = null;
var closingHardMobXpSettings = hardMobXpSettings; hardMobXpSettings = null;
var closingAdventureQuestSettings = adventureQuestSettings; adventureQuestSettings = null;
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
@@ -780,6 +838,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingPartyEconomySettings != null) cleanup("party economy configuration", closingPartyEconomySettings::close);
if(closingGearSettings!=null)cleanup("gear configuration",closingGearSettings::close);
if (closingCoinSettings != null) cleanup("coin configuration", closingCoinSettings::close);
if (closingHardMobXpSettings != null) cleanup("hard-mob XP configuration", closingHardMobXpSettings::close);
if (closingAdventureQuestSettings != null) cleanup("adventure quest configuration", closingAdventureQuestSettings::close);
if (closingAdventureQuests != null) cleanup("adventure quest persistence", closingAdventureQuests::close);
if (closingAdventureQuestController != null) cleanup("adventure quests", closingAdventureQuestController::close);
if (closingDungeonController != null) cleanup("dungeons", closingDungeonController::close);
if (closingDungeons != null) cleanup("dungeon persistence", closingDungeons::close);
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
if (closingOutOfCombatRecoverySettings != null) cleanup("out-of-combat recovery configuration", closingOutOfCombatRecoverySettings::close);
@@ -802,6 +866,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingGearSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingGearSettings.drained().toCompletableFuture(),
closingPartyEconomySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartyEconomySettings.drained().toCompletableFuture(),
closingHardMobXpSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingHardMobXpSettings.drained().toCompletableFuture(),
closingAdventureQuestSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdventureQuestSettings.drained().toCompletableFuture(),
closingAdventureQuests == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdventureQuests.drained().toCompletableFuture(),
closingDungeons == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingDungeons.drained().toCompletableFuture(),
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -910,6 +982,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingTrainingDummyController);
unregister(closingPartyRegistrarMenus);
unregister(closingArmorerController);unregister(closingArmorShopMenus);
unregister(closingAdventureQuestController);
unregister(closingDungeonController);
unregister(closingCombatExperience);
unregister(closingHealthSteal);
unregister(closingFireball);
@@ -969,13 +1043,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules(java.math.BigInteger hard) {
try {
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, hard, boss, spacing);
} catch (NumberFormatException invalid) {
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
}
@@ -13,9 +13,10 @@ import org.bukkit.Material;
/** Immutable internal ability metadata and focused effect boundary; not an external add-on API. */
public record AbilityDefinition(String id, String name, Material icon, List<String> lore, String instructions,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Activation activation,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Category category, Activation activation,
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
TargetPolicy targetPolicy, AbilityEffect effect) {
public enum Category { ATK, BUF, HEAL }
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT,
EXCEPTIONAL_NONCOMBAT_SELECTION }
@@ -25,7 +26,7 @@ public record AbilityDefinition(String id, String name, Material icon, List<Stri
public AbilityDefinition {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(icon);
Objects.requireNonNull(lore); Objects.requireNonNull(instructions); Objects.requireNonNull(requirements);
Objects.requireNonNull(pointCost); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(pointCost); Objects.requireNonNull(category); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(cooldown); Objects.requireNonNull(parameters); Objects.requireNonNull(targetPolicy);
Objects.requireNonNull(effect);
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
@@ -72,7 +72,7 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2))
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
"Use while aiming at the first visible hostile within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks.",
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
@@ -76,7 +76,7 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
List.of("Launch a bounded fiery arrow flight.", "Deals " + current.baseDamage() + " + INT modifier damage."),
"Use the equipped snowball; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
@@ -102,7 +102,7 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
+ current.baseDamage() + " + INT modifier damage.",
"Applies Slowness " + level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + " seconds."),
"Use to affect exposed enemies; caster, party and ordinary players are excluded.",
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
@@ -114,7 +114,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
"Use the equipped shard; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
@@ -50,7 +50,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
List.of("Charge a contained melee strike.",
"Fully charged: " + settings.baseDamage() + " + STR modifier damage."),
"Attack with the equipped sword; partial charge follows the native attack curve.",
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(),
Map.of("base-damage", new BigDecimal(settings.baseDamage())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
@@ -130,7 +130,7 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
List.of("Redirect " + decimal(current.redirectPercent()) + "% of an aimed party ally's combat damage.",
"Range " + decimal(BigDecimal.valueOf(current.range())) + " blocks for " + seconds(current.duration()) + " seconds."),
"Use on an aimed active party ally; one guardian and one bond per ally.",
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
@@ -30,7 +30,7 @@ public final class Hex implements AbilityEffect,AutoCloseable{
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("hex","Hex",Material.SPIDER_EYE,
List.of("Reduce an aimed hostile's combat damage by "+current.reductionPercent().stripTrailingZeros().toPlainString()+"%.",
"Range "+BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString()+" blocks; lasts "+seconds(current.duration())+" seconds."),
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("range",BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
AbilityDefinition.TargetPolicy.HOSTILE,this);}
@@ -5,7 +5,7 @@ public final class Inspire implements AbilityEffect,AutoCloseable{
public record Settings(BigInteger baseTemporaryHealth,Duration cooldown,double range,Duration duration){public Settings{Objects.requireNonNull(baseTemporaryHealth);Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);if(baseTemporaryHealth.signum()<0||baseTemporaryHealth.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger())>0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0)throw new IllegalArgumentException("Invalid Inspire settings");}public static Settings defaults(){return new Settings(BigInteger.valueOf(4),Duration.ofSeconds(15),12,Duration.ofSeconds(6));}}
private volatile Settings settings;private final TemporaryHealth temporaryHealth;private boolean closed;
public Inspire(Settings settings,TemporaryHealth temporaryHealth){this.settings=Objects.requireNonNull(settings);this.temporaryHealth=Objects.requireNonNull(temporaryHealth);}public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
@@ -83,7 +83,7 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
List.of("Draw and release an ammunition-free owned arrow.",
"A full draw deals " + current.baseDamage() + " + DEX modifier damage."),
"Draw by holding use; release after at least " + current.minimumDrawTicks() + " ticks.",
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE,
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.DRAW_RELEASE, current.recovery(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"minimum-draw-ticks", BigDecimal.valueOf(current.minimumDrawTicks()),
@@ -70,7 +70,7 @@ public final class MendingLight implements AbilityEffect {
List.of("Restore a party ally's missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier within " + decimal(current.range()) + " blocks."),
"Use while aiming at an injured active party ally; self and full-health targets are refused.",
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
@@ -59,7 +59,7 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("meteor","Meteor",Material.FIRE_CHARGE,
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("base-damage",new BigDecimal(current.baseDamage()),"target-range",BigDecimal.valueOf(current.targetRange()).stripTrailingZeros(),"warning-seconds",nanos(current.warning()),"radius",BigDecimal.valueOf(current.radius()).stripTrailingZeros(),"vertical-range",BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros()),AbilityDefinition.TargetPolicy.AREA,this);}
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@@ -58,7 +58,7 @@ public final class Purify implements AbilityEffect {
List.of("Remove Poison, Wither, Slowness and Weakness.",
"Aimed party-ally range: " + decimal(current.range()) + " blocks; beneficial effects remain."),
"Sneak+Use cleanses self; normal Use requires an aimed active party ally.",
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PARTY, this);
@@ -80,7 +80,7 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
List.of("Dash forward with native horizontal movement.",
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
"Use while grounded, on foot, and not flying or gliding.",
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
@@ -175,7 +175,7 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
+ " and " + decimal(current.damageBonusPercent()) + "% combat damage.",
"A " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere lasting " + seconds(current.duration()) + " seconds."),
"Use to rally yourself and nearby active party members; copies do not stack.",
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
@@ -85,7 +85,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
"Use the equipped shard while aiming at another player within 12 blocks.",
Map.of(Stat.WIS, BigInteger.valueOf(30), Stat.CHR, BigInteger.valueOf(24), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
BigInteger.ONE, AbilityDefinition.Category.HEAL, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
Map.of("range", BigDecimal.valueOf(12), "selection-time", BigDecimal.valueOf(300),
"death-age", BigDecimal.valueOf(86400)),
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION, this);
@@ -174,7 +174,7 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
"Party members inside heal " + decimal(current.healingPerPulse()) + " each second and take "
+ decimal(current.damageReductionPercent()) + "% less combat damage."),
"Use to hold exposed ground; healing starts after one second and the area does not follow you.",
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
@@ -51,7 +51,7 @@ public final class SecondWind implements AbilityEffect {
List.of("Restore missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
"Use while injured; full health refuses without spending cooldown.",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing())),
AbilityDefinition.TargetPolicy.SELF, this);
@@ -61,7 +61,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
List.of("Strike within " + decimal(current.range()) + " blocks for " + current.baseDamage() + " + STR modifier damage.",
"Accepted damage adds knockback; this shield never enables ordinary blocking."),
"Attack a visible hostile at short range.", Map.of(Stat.STR, BigInteger.valueOf(12), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
BigInteger.ONE, AbilityDefinition.Category.ATK, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()), "range", decimalValue(current.range()),
"horizontal-knockback", decimalValue(current.horizontalKnockback()),
"minimum-vertical-knockback", decimalValue(current.minimumVerticalKnockback())),
@@ -53,7 +53,7 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
@@ -181,7 +181,7 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
"Party members in its " + decimal(current.radius()) + "-block area gain " + decimal(current.damageBonusPercent())
+ "% combat damage and " + decimal(current.knockbackResistancePercent()) + "% knockback resistance."),
"Use while looking at the solid top surface where the fixed banner should stand; copies do not stack.",
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("placement-range", BigDecimal.valueOf(current.placementRange()).stripTrailingZeros(),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
@@ -0,0 +1,27 @@
package games.dmg.fantasystick.adventure;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.World;
/** Readable presentation for authoritative UUID-backed adventure locations. */
public final class AdventureLocationPresentation {
private final Server server;
public AdventureLocationPresentation(Server server) {
this.server = Objects.requireNonNull(server);
}
public String format(UUID worldId, double x, double y, double z) {
Objects.requireNonNull(worldId);
World world = server.getWorld(worldId);
String worldName = world == null || world.getName() == null || world.getName().isBlank()
? "unavailable world" : world.getName();
return worldName + " (" + block(x) + ", " + block(y) + ", " + block(z) + ")";
}
private static int block(double coordinate) {
return (int) Math.floor(coordinate);
}
}
@@ -0,0 +1,30 @@
package games.dmg.fantasystick.adventure;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
/** Registered command adapter for player inspection and acknowledged adventure administration. */
public final class AdventureQuestCommands {
private static final String PERMISSION="fantasystick.admin";
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;
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());}
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 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 [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.");}}));}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(!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==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")&&args[1].equals("remove"))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 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> [<radius>|<x-blocks> <z-blocks>] [name...]|remove <site-uuid>|list>");}
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fantasystick quest-giver <place [name...]|remove <giver-uuid>|list>");}
}
@@ -0,0 +1,113 @@
package games.dmg.fantasystick.adventure;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.RewardAllocator;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import org.bukkit.*;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.*;
import org.bukkit.attribute.Attribute;
import org.bukkit.boss.*;
import org.bukkit.event.*;
import org.bukkit.event.entity.*;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.entity.PlayerLeashEntityEvent;
import org.bukkit.inventory.*;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
/** Native Quest Givers, shared camp/champion attempts, eligibility containment and idempotent completion rewards. */
public final class AdventureQuestController implements Listener,AutoCloseable {
private static final String GIVER="quest_giver",ENEMY="adventure_enemy",PROJECTILE="adventure_projectile";
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestSettings settings;
private final AdventureLocationPresentation locations;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
private final Consumer<Runnable> nextTick;private final Clock clock;private final java.util.random.RandomGenerator random;
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,menuSiteKey,menuCatalogKey,menuPageKey;
private final 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 org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@Override public Inventory getInventory(){return null;}}
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);locations=new AdventureLocationPresentation(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 boolean hasActiveEncounter(){return !attempts.isEmpty()||!starting.isEmpty();}
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 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();var work=resetParticipants.stream().map(participant->{try{return participant.reset(reset).toCompletableFuture();}catch(RuntimeException failure){return CompletableFuture.<Void>failedFuture(failure);}}).toArray(CompletableFuture[]::new);resetWork=CompletableFuture.allOf(work).thenCompose(ignored->directory.completeReset(reset.operation())).whenComplete((ignored,failure)->resetWork=null);return resetWork;}
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.displayName()));villager.setCustomNameVisible(true);}
public void open(Player player){open(player,0);}
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{if(failure!=null||context(player)==null){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(site==null?Material.BARRIER:switch(site.type()){case CAMP->Material.CAMPFIRE;case CHAMPION->Material.NETHERITE_SWORD;case DUNGEON->Material.IRON_DOOR;});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||!directory.available(site)?"Unavailable / cleared":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";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=active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=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()));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null&&directory.findGiver(id).isPresent())open(event.getPlayer());}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);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 "+locations.format(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 "+locations.format(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().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);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)player.spawnParticle(Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
private static List<double[]> perimeter(AdventureQuestState.Arena arena){var result=new ArrayList<double[]>();if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE){int count=Math.min(256,Math.max(16,(int)Math.ceil(Math.PI*arena.xBlocks())));for(int index=0;index<count;index++){double angle=2*Math.PI*index/count;result.add(new double[]{Math.cos(angle)*arena.xBlocks(),Math.sin(angle)*arena.xBlocks()});}}else{double halfX=arena.xBlocks()/2d,halfZ=arena.zBlocks()/2d;edge(result,-halfX,halfX,-halfZ,true);edge(result,-halfX,halfX,halfZ,true);edge(result,-halfZ,halfZ,-halfX,false);edge(result,-halfZ,halfZ,halfX,false);}return result.size()<=256?List.copyOf(result):List.copyOf(result.subList(0,256));}
private static void edge(List<double[]> result,double from,double to,double fixed,boolean xAxis){for(double value=from;value<to;value+=2)result.add(xAxis?new double[]{value,fixed}:new double[]{fixed,value});result.add(xAxis?new double[]{to,fixed}:new double[]{fixed,to});}
private void contain(Attempt attempt){var site=directory.findSite(attempt.site).orElse(null);if(site==null)return;var world=plugin.getServer().getWorld(site.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity))&&!inside(entity.getLocation(),site,attempt.arena)){relocating.add(entity);try{entity.teleport(new Location(world,site.x(),site.y(),site.z()));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
private java.util.stream.Stream<Player> eligibleInside(UUID site){var value=directory.findSite(site).orElse(null);return value==null?java.util.stream.Stream.empty():eligibleInside(site,arena(value));}
private java.util.stream.Stream<Player> eligibleInside(UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return java.util.stream.Stream.empty();return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,site)!=null).filter(player->inside(player.getLocation(),value,arena));}
private AdventureQuestState.Quest eligible(Player player,UUID site){var context=context(player);if(context==null)return null;var quest=directory.activeFor(context.character,context.partyId).orElse(null);return quest!=null&&quest.site().equals(site)&&quest.snapshot().contains(context.character)?quest:null;}
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON)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.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 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);});});}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void splash(org.bukkit.event.entity.PotionSplashEvent event){if(!managed(event.getPotion()))return;UUID site=site(event.getPotion());for(var affected:List.copyOf(event.getAffectedEntities()))if(!(affected instanceof Player player)||site==null||eligible(player,site)==null)event.setIntensity(affected,0);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(enemy(event.getEntity())&&event.getTarget()!=null){UUID site=site(event.getEntity());if(!(event.getTarget() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(org.bukkit.event.entity.ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&enemy(shooter)){event.getEntity().getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,PROJECTILE);event.getEntity().getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,Objects.requireNonNull(site(shooter)).toString());}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(EntityCombustEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(EntityTransformEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void portal(EntityPortalEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void pickup(EntityPickupItemEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void block(EntityChangeBlockEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(EntityBreedEvent event){if(giver(event.getMother())||giver(event.getFather()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(giver(event.getEntered())||managed(event.getEntered()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(PlayerLeashEntityEvent event){if(giver(event.getEntity())||managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if((giver(event.getEntity())||managed(event.getEntity()))&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(enemy(entity)){UUID site=site(entity);var attempt=site==null?null:attempts.remove(site);if(attempt!=null){cleanup(attempt);readyAt.put(site,clock.instant());}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void giverDamage(EntityDamageEvent event){if(giver(event.getEntity()))event.setCancelled(true);}
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof org.bukkit.entity.Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
private UUID site(Entity entity){return parse(entity.getPersistentDataContainer().get(siteKey,PersistentDataType.STRING));}
private void syncBossBar(Attempt attempt){if(attempt.bossBar==null||attempt.champion==null)return;var viewers=eligibleInside(attempt.site,attempt.arena).collect(java.util.stream.Collectors.toSet());for(var player:plugin.getServer().getOnlinePlayers())if(viewers.contains(player))attempt.bossBar.addPlayer(player);else attempt.bossBar.removePlayer(player);double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,attempt.champion.getHealth()/maximum)));}
private void cleanup(Attempt attempt){if(attempt.bossBar!=null)attempt.bossBar.removeAll();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity)))entity.remove();attempt.entities.clear();}
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
private static boolean inside(Location location,AdventureQuestState.Site site,AdventureQuestState.Arena arena){return location.getWorld()!=null&&location.getWorld().getUID().equals(site.world())&&arena.contains(location.getX()-site.x(),location.getZ()-site.z());}
private Location location(AdventureQuestState.Giver value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
private static UUID parse(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
private static UUID id(Entity entity,NamespacedKey key){return parse(entity.getPersistentDataContainer().get(key,PersistentDataType.STRING));}
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
@Override public void close(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
}
@@ -0,0 +1,69 @@
package games.dmg.fantasystick.adventure;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import java.io.IOException;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Supplier;
import org.bukkit.entity.EntityType;
/** Serialized durable daily catalog, acceptance, roster and completion authority. */
public final class AdventureQuestDirectory implements AutoCloseable {
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, CONFLICT, CLAIMED }
public enum RerollStatus { APPLIED, STALE }
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 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 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 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){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();var catalog=state.catalog()==null?null:new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites().stream().filter(v->!v.equals(id)).toList());return result(copy(state,sites,state.givers(),catalog,state.quests().stream().filter(v->!v.site().equals(id)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<Optional<AdventureQuestState.Giver>> removeGiver(UUID id){return mutate(state->{var found=state.givers().stream().filter(v->v.id().equals(id)).findFirst();if(found.isEmpty())return result(state,Optional.empty());return result(copy(state,state.sites(),state.givers().stream().filter(v->!v.id().equals(id)).toList(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
public CompletableFuture<AdventureQuestState.Catalog> appendOffer(UUID site){return mutate(state->{if(state.reset()!=null||site(state,site).filter(v->v.type()==AdventureQuestState.SiteType.DUNGEON).isEmpty()||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC))))throw new IllegalArgumentException("Dungeon offer unavailable");if(state.catalog().sites().contains(site))return result(state,state.catalog());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);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))return result(state,false);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites().stream().filter(id->!id.equals(site)).toList());var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();return result(copy(state,state.sites(),state.givers(),catalog,quests,state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{if(state.reset()!=null)return result(state,state.catalog());var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var prior=state.catalog();var catalog=roll(state,today,prior==null?Set.of():Set.copyOf(prior.sites()));var reset=prior==null?null:new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,catalog);});}
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&&current.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
public CompletableFuture<Boolean> completeReset(UUID operation){return mutate(state->{if(state.reset()==null||!state.reset().operation().equals(operation))return result(state,false);return result(copyReset(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),null),true);});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){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)||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);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<Integer> expireOfferQuests(UUID site){return mutate(state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.reset()!=null||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
public CompletableFuture<Boolean> complete(UUID questId,Set<UUID> recipients){return mutate(state->{var quest=state.quests().stream().filter(q->q.id().equals(questId)).findFirst().orElse(null);if(quest==null)return result(state,false);var units=new HashSet<>(state.unitClaims());units.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));var characters=new HashSet<>(state.characterClaims());for(UUID recipient:recipients)characters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),recipient));return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(questId)).toList(),units,characters,state.rosters()),true);});}
public List<AdventureQuestState.Quest> active(){return expireView(snapshot).quests();}
public boolean characterClaimed(UUID catalog,UUID site,UUID character){return snapshot.characterClaims().stream().anyMatch(claim->claim.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 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 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 candidates=candidateSites(state);var alternatives=candidates.stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=candidates.stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);int count=Math.min(settings.get().dailySiteCount(),candidates.size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected);}
private void shuffle(List<UUID> values){for(int i=values.size()-1;i>0;i--){int j=random.nextInt(i+1);var v=values.get(i);values.set(i,values.get(j));values.set(j,v);}}
private List<AdventureQuestState.Site> candidateSites(AdventureQuestState state){var values=new LinkedHashMap<UUID,AdventureQuestState.Site>();state.sites().stream().filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON).forEach(value->values.put(value.id(),value));dungeonSites.get().forEach(value->values.put(value.id(),value));return List.copyOf(values.values());}
private Optional<AdventureQuestState.Site> site(AdventureQuestState state,UUID id){var stored=find(state.sites(),id);return stored.isPresent()?stored:dungeonSites.get().stream().filter(value->value.id().equals(id)).findFirst();}
private static Optional<AdventureQuestState.Site> find(List<AdventureQuestState.Site> sites,UUID id){return sites.stream().filter(v->v.id().equals(id)).findFirst();}
private static AdventureQuestState copy(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters){return copyReset(original,sites,givers,catalog,quests,unitClaims,characterClaims,rosters,original.reset());}
private static AdventureQuestState copyReset(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,AdventureQuestState.Reset reset){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,reset);}
private record Result<T>(AdventureQuestState state,T value){}
private static <T>Result<T> result(AdventureQuestState state,T value){return new Result<>(state,value);}
private <T>CompletableFuture<T> mutate(java.util.function.Function<AdventureQuestState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Adventure directory closed"));return future;}pending++;}worker.execute(()->{try{var result=operation.apply(snapshot);if(result.state()!=snapshot){repository.save(result.state());snapshot=result.state();}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,66 @@
package games.dmg.fantasystick.adventure;
import java.math.BigInteger;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
import org.bukkit.entity.EntityType;
/** Immutable durable authority for registered daily-adventure locations, offers, quests and claims. */
public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,
Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,
Map<UUID,List<EntityType>> rosters,Reset reset) {
public enum SiteType { CAMP, CHAMPION, DUNGEON }
public enum UnitType { SOLO, PARTY }
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 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 Instant expiresAt(){return day.plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant();}
}
public record Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins) {
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit))))throw new IllegalArgumentException("Invalid quest"); }
}
public record UnitClaim(UUID catalog,UUID site,UnitType type,UUID unit) {
public UnitClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(type);Objects.requireNonNull(unit);}
}
public record CharacterClaim(UUID catalog,UUID site,UUID character) {
public CharacterClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}
}
public record 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,null);}
public AdventureQuestState {
sites=List.copyOf(sites);givers=List.copyOf(givers);quests=List.copyOf(quests);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);
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());
}
public static AdventureQuestState empty(){return new AdventureQuestState(List.of(),List.of(),null,List.of(),Set.of(),Set.of(),Map.of(),null);}
private static String validName(String name){Objects.requireNonNull(name);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(c->{int t=Character.getType(c);return t==Character.CONTROL||t==Character.FORMAT||t==Character.SURROGATE;}))throw new IllegalArgumentException("Invalid name");return name;}
private static void finite(double... values){for(double value:values)if(!Double.isFinite(value))throw new IllegalArgumentException("Invalid position");}
private static void unique(List<UUID> values){if(new HashSet<>(values).size()!=values.size())throw new IllegalArgumentException("Duplicate identity");}
}
@@ -0,0 +1,9 @@
package games.dmg.fantasystick.adventure;
import java.util.concurrent.CompletionStage;
/** Durable extension point for state that must join the authoritative adventure-day reset. */
@FunctionalInterface
public interface AdventureResetParticipant {
CompletionStage<Void> reset(AdventureQuestState.Reset reset);
}
@@ -0,0 +1,46 @@
package games.dmg.fantasystick.adventure;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.IntSupplier;
import java.util.function.IntUnaryOperator;
import org.bukkit.entity.EntityType;
/** Fixed weighted monster-camp roster generation with reviewed guarantees and caps. */
public final class CampRoster {
private CampRoster() { }
public static List<EntityType> roll(int count, java.util.random.RandomGenerator random) {
Objects.requireNonNull(random);
return roll(count, () -> random.nextInt(100), random::nextInt);
}
static List<EntityType> roll(int count, IntSupplier percent, IntUnaryOperator shuffleIndex) {
Objects.requireNonNull(percent); Objects.requireNonNull(shuffleIndex);
if (count < 4 || count > 64) throw new IllegalArgumentException("Camp enemy count must be 4 through 64");
int guaranteed = (count + 1) / 2;
int pillagerCap = count / 2;
int vindicatorCap = Math.max(1, count / 4);
int pillagers = 0, vindicators = 0, witches = 0;
var result = new ArrayList<EntityType>(count);
for (int index = 0; index < guaranteed; index++) result.add(EntityType.HUSK);
while (result.size() < count) {
int roll = percent.getAsInt();
if (roll < 0 || roll >= 100) throw new IllegalArgumentException("Camp roll must be 0 through 99");
EntityType selected;
if (roll < 40) selected = EntityType.HUSK;
else if (roll < 75 && pillagers < pillagerCap) { selected = EntityType.PILLAGER; pillagers++; }
else if (roll < 95 && roll >= 75 && vindicators < vindicatorCap) { selected = EntityType.VINDICATOR; vindicators++; }
else if (roll >= 95 && witches < 1) { selected = EntityType.WITCH; witches++; }
else selected = EntityType.HUSK;
result.add(selected);
}
for (int index = result.size() - 1; index > 0; index--) {
int other = shuffleIndex.applyAsInt(index + 1);
if (other < 0 || other > index) throw new IllegalArgumentException("Invalid shuffle index");
var value = result.get(index); result.set(index, result.get(other)); result.set(other, value);
}
return List.copyOf(result);
}
}
@@ -142,7 +142,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::category)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::id))
.toList();
var passiveDefinitions = passives.definitions().stream()
@@ -158,35 +159,51 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
games.dmg.fantasystick.passive.PassiveDefinition::name));
var passiveDetails = passiveDefinitions.stream()
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView)).toList();
var placeholderPassiveDetails = passiveDefinitions.stream()
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView, 1)).toList();
var placeholderTargets = definitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.ability.AbilityDefinition::id, ignored -> 1));
var passivePlaceholderTargets = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id, ignored -> 1));
var placeholderDetails = definitions.stream()
.map(definition -> abilityPages(record, definition, abilityView, 1)).toList();
int activeCount = activePages(record, definitions, placeholderTargets).size();
int passiveCount = passiveSlotPages(record, passiveDefinitions, passivePlaceholderTargets).size();
int indexCount = indexPages(definitions, placeholderTargets).size();
int passiveIndexCount = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets).size();
var placeholderIndex = indexPages(definitions, placeholderTargets);
int indexCount = placeholderIndex.pages().size();
var placeholderPassiveIndex = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets);
int passiveIndexCount = placeholderPassiveIndex.pages().size();
int indexStart = pages.size() + activeCount + passiveCount;
var indexTargets = new java.util.HashMap<String, Integer>();
placeholderIndex.relativePageByAbility().forEach((id, page) -> indexTargets.put(id, indexStart + page + 1));
int passiveIndexStart = indexStart + indexCount;
var passiveIndexTargets = new java.util.HashMap<String, Integer>();
placeholderPassiveIndex.relativePageByPassive().forEach((id, page) ->
passiveIndexTargets.put(id, passiveIndexStart + page + 1));
var targets = new java.util.HashMap<String, Integer>();
int target = pages.size() + activeCount + passiveCount + indexCount + passiveIndexCount + 1;
for (int index = 0; index < definitions.size(); index++) {
targets.put(definitions.get(index).id(), target);
target += details.get(index).size();
target += placeholderDetails.get(index).size();
}
var passiveTargets = new java.util.HashMap<String, Integer>();
for (int index = 0; index < passiveDefinitions.size(); index++) {
passiveTargets.put(passiveDefinitions.get(index).id(), target);
target += passiveDetails.get(index).size();
target += placeholderPassiveDetails.get(index).size();
}
var index = indexPages(definitions, targets);
var details = definitions.stream()
.map(definition -> abilityPages(record, definition, abilityView, indexTargets.get(definition.id()))).toList();
var passiveIndex = passiveIndexPages(passiveDefinitions, passiveTargets);
var passiveDetails = passiveDefinitions.stream().map(definition -> passiveDetailPages(record, definition,
passiveNames, passiveView, passiveIndexTargets.get(definition.id()))).toList();
pages.addAll(activePages(record, definitions, targets));
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
pages.addAll(indexPages(definitions, targets));
pages.addAll(passiveIndexPages(passiveDefinitions, passiveTargets));
pages.addAll(index.pages());
pages.addAll(passiveIndex.pages());
details.forEach(pages::addAll);
passiveDetails.forEach(pages::addAll);
}
@@ -274,34 +291,61 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return builder.pages();
}
private static java.util.List<Component> indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
builder.link("", definition.name(), targets.get(definition.id()));
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
private record AbilityIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByAbility) {
private AbilityIndex { pages = java.util.List.copyOf(pages); relativePageByAbility = Map.copyOf(relativePageByAbility); }
}
private static java.util.List<Component> passiveIndexPages(
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
private static AbilityIndex indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var relativePages = new java.util.HashMap<String, Integer>();
for (var category : games.dmg.fantasystick.ability.AbilityDefinition.Category.values()) {
var grouped = definitions.stream().filter(definition -> definition.category() == category).toList();
int cursor = 0;
while (cursor < grouped.size()) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.text(category.name());
int usedLines = 0;
while (cursor < grouped.size()) {
var definition = grouped.get(cursor);
int linkLines = BookPagination.lines(definition.name()).size();
if (usedLines > 0 && usedLines + linkLines > 11) break;
relativePages.put(definition.id(), pages.size());
builder.link("", definition.name(), targets.get(definition.id()));
usedLines += linkLines; cursor++;
}
pages.addAll(builder.pages());
}
}
return new AbilityIndex(pages, relativePages);
}
private record PassiveIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByPassive) {
private PassiveIndex { pages = java.util.List.copyOf(pages); relativePageByPassive = Map.copyOf(relativePageByPassive); }
}
private static PassiveIndex passiveIndexPages(
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>(); var relativePages = new java.util.HashMap<String, Integer>();
int cursor = 0;
while (cursor < definitions.size()) {
var builder = new PageBuilder(); builder.text("Passive index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
int usedLines = 0;
while (cursor < definitions.size()) {
var definition = definitions.get(cursor);
int linkLines = BookPagination.lines(definition.name()).size();
if (usedLines > 0 && usedLines + linkLines > 11) break;
relativePages.put(definition.id(), pages.size());
builder.link("", definition.name(), targets.get(definition.id()));
usedLines += linkLines; cursor++;
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
return new PassiveIndex(pages, relativePages);
}
private static java.util.List<Component> passiveDetailPages(CharacterRecord record,
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view) {
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view,
int indexPage) {
String requirements = definition.requirements().entrySet().stream().sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + " " + entry.getValue()).collect(java.util.stream.Collectors.joining(", "));
if (!definition.prerequisites().isEmpty()) {
@@ -322,11 +366,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
return builder.pages();
}
private static java.util.List<Component> abilityPages(CharacterRecord record,
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view, int indexPage) {
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
.collect(java.util.stream.Collectors.joining(", "));
@@ -339,6 +385,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
var builder = new PageBuilder();
builder.text(definition.name());
builder.text(definition.category().name());
builder.text(requirements);
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
@@ -351,6 +398,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(java.util.stream.Collectors.joining(", ")));
builder.text(state);
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
return builder.pages();
}
@@ -35,9 +35,10 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
final ControlInput input;
final LivingEntity target;
final Instant attackedAt;
final boolean knockback;
boolean accepted;
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
this.input = input; this.target = target; this.attackedAt = attackedAt;
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
}
}
@@ -72,7 +73,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
if (ready != null && now.isBefore(ready)) return;
BigInteger damage = damage(record.stats().get(Stat.STR));
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
var attempt = new Pending(input, target, now);
var attempt = new Pending(input, target, now,
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
pending = attempt;
try {
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
@@ -82,21 +84,27 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
});
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
if (attempt.accepted) {
readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
if (attempt.knockback && !trainingDamage.target(target) && input.stillCurrent()
&& sessions.current(input.session())) {
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
}
}
}
}
static BigInteger damage(BigInteger strength) {
Objects.requireNonNull(strength);
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.ONE).max(BigInteger.ONE);
}
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
@@ -0,0 +1,60 @@
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.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();
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());}
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"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);respond(sender,()->sender.sendMessage(failure==null?"Created room "+value.id()+" ("+value.displayName()+").":"Room creation refused: geometry, limits or persistence failed."));}
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&&catalog.sites().contains(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 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"):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").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||!catalog.sites().contains(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> ...");}
}
@@ -0,0 +1,156 @@
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 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=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);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&&catalog.sites().contains(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(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)).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){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 work=recipients.stream().map(character->awardOne(dungeon,room,generation,quest,character,xp.get(character),coins.get(character))).toArray(CompletableFuture[]::new);return CompletableFuture.allOf(work).thenCompose(ignored->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();}
private CompletableFuture<Void> awardOne(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,UUID character,BigInteger xp,BigInteger coins){return characters.find(character).thenCompose(found->{if(found.isEmpty())return CompletableFuture.failedFuture(new IllegalStateException("Character unavailable"));return attemptAward(dungeon,room,generation,quest,found.orElseThrow(),xp,coins);});}
private CompletableFuture<Void> attemptAward(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,games.dmg.fantasystick.persistence.CharacterRecord record,BigInteger xp,BigInteger coins){UUID receipt=UUID.nameUUIDFromBytes((generation+":"+dungeon.id()+":"+room.id()+":"+quest.unit()+":"+record.id()).getBytes(StandardCharsets.UTF_8));return characters.awardCombatReward(record.id(),record.revision(),receipt,xp,coins,progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).thenCompose(award->{if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent())return attemptAward(dungeon,room,generation,quest,award.record().orElseThrow(),xp,coins);if(award.record().isEmpty())return CompletableFuture.failedFuture(new IllegalStateException("Reward unavailable"));var saved=award.record().orElseThrow();var done=new CompletableFuture<Void>();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("Dungeon room cleared: earned "+xp+" fantasy XP and "+coins+" character coins.");done.complete(null);});return done;});}
@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;player.spawnParticle(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,69 @@
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 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=Objects.requireNonNull(repository);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");if(!dungeon.rooms().isEmpty()&&dungeon.rooms().stream().noneMatch(value->value.bounds().touchesFace(bounds)))throw new IllegalArgumentException("Room is disconnected");var rooms=new ArrayList<>(dungeon.rooms());rooms.add(room);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))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);}
private static void validate(DungeonState.Dungeon dungeon){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()))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");}
static boolean connected(List<DungeonState.Room> rooms){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().touchesFace(room.bounds())){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,59 @@
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 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;
@@ -93,6 +93,9 @@ public final class SessionControls {
var meta = item.getItemMeta();
meta.displayName(Component.text(icon.name()));
meta.setMaxStackSize(1);
if (slot == 1 && character.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID)) {
meta.addEnchant(org.bukkit.enchantments.Enchantment.KNOCKBACK, 1, true);
}
if (cooling) meta.setItemModel(COOLDOWN_MODEL);
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
@@ -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; }
@@ -57,7 +57,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
private final CharacterStorage characters;
private final ProgressionRules progression;
private final CombatRewardRules rewards;
private volatile CombatRewardRules rewards;
private final EquipmentSessions sessions;
private final Clock clock;
private final Consumer<Runnable> nextTick;
@@ -73,6 +73,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
private org.bukkit.scheduler.BukkitTask retryTask;
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
private boolean closed;
private final class PendingAward {
@@ -81,12 +82,13 @@ public final class CombatExperience implements Listener, AutoCloseable {
final BigInteger experience;
final BigInteger coins;
final CombatRewardRules.KillPosition restricted;
final CombatRewardRules rewardRules;
BigInteger expectedRevision;
boolean running;
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
CombatRewardRules.KillPosition restricted) {
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
this.id = id; this.character = character; this.experience = experience;
this.coins = coins; this.restricted = restricted;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
}
}
@@ -113,6 +115,11 @@ public final class CombatExperience implements Listener, AutoCloseable {
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
}
public void attachExcluded(java.util.function.Predicate<org.bukkit.entity.Entity> predicate) {
if (closed) throw new IllegalStateException("Combat experience is closed");
excluded = Objects.requireNonNull(predicate);
}
public void install(org.bukkit.plugin.Plugin plugin) {
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
@@ -134,7 +141,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (closed || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
if (closed || excluded.test(event.getEntity()) || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
|| event.getFinalDamage() <= 0) return;
var origin = sources.source(event);
if (origin == null || origin.session() == null || !sources.current(origin)
@@ -172,7 +179,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void mobDeath(EntityDeathEvent event) {
if (closed || event instanceof PlayerDeathEvent) return;
if (closed || event instanceof PlayerDeathEvent || excluded.test(event.getEntity())) return;
award(event.getEntity(), classify(event.getEntity()), 0, null, BigInteger.ZERO);
}
@@ -193,14 +200,16 @@ public final class CombatExperience implements Listener, AutoCloseable {
BigInteger coinPool = defeatedCharacter == null
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
if (units.isEmpty()) return; // The defeat transition has already persisted any PvP balance loss.
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(rewards.pool(kind, level), units, random);
var capturedRewards = rewards;
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(kind, level), units, random);
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
UUID targetReward = defeatedCharacter == null ? target.getUniqueId() : defeatedCharacter;
for (UUID character : xp.keySet()) {
UUID receipt = UUID.nameUUIDFromBytes((targetReward + ":" + character + ":" + now).getBytes(java.nio.charset.StandardCharsets.UTF_8));
var operation = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted);
var operation = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,
capturedRewards);
pending.put(operation.id, operation);
attempt(operation);
}
@@ -259,7 +268,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
return;
}
characters.awardCombatReward(operation.character, operation.expectedRevision, operation.id,
operation.experience, operation.coins, progression, rewards, operation.restricted).whenComplete((award, failure) -> {
operation.experience, operation.coins, progression, operation.rewardRules, operation.restricted).whenComplete((award, failure) -> {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
@@ -319,9 +328,17 @@ public final class CombatExperience implements Listener, AutoCloseable {
}
}
public void configure(CombatRewardRules updated) {
if (closed) throw new IllegalStateException("Combat rewards are closed");
rewards = Objects.requireNonNull(updated);
}
public CombatRewardRules rules() { return rewards; }
public static CombatRewardRules.Target classify(LivingEntity entity) {
EntityType type = entity.getType();
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
if (type == EntityType.WITCH) return CombatRewardRules.Target.HARD;
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
}
@@ -30,6 +30,7 @@ public final class PartyRoster implements AutoCloseable {
private UUID character(UUID player){return sessions.active(player).filter(sessions::current).map(EquipmentSessions.Active::character).orElse(null);}
private List<UUID> activePlayers(CharacterParty party){return party.members().stream().map(sessions::activeCharacter).flatMap(Optional::stream).filter(sessions::current).map(EquipmentSessions.Active::player).sorted().toList();}
public Optional<UUID> activePlayer(UUID character){if(durable==null)return Optional.of(character);return sessions.activeCharacter(character).filter(sessions::current).map(EquipmentSessions.Active::player);}
public boolean exists(UUID party){if(durable!=null)return durable.ready()&&durable.all().stream().anyMatch(value->value.id().equals(party));return membership.values().stream().anyMatch(value->value.id().equals(party));}
private Optional<Party> project(CharacterParty party){var members=activePlayers(party);UUID leader=sessions.activeCharacter(party.leader()).filter(sessions::current).map(EquipmentSessions.Active::player).orElse(party.leader());return Optional.of(new Party(party.id(),leader,members,party.leader(),party.members().stream().sorted().toList()));}
private Party managedBy(UUID leader,UUID party){var group=membership.get(leader);return group!=null&&group.id().equals(party)&&group.leader().equals(leader)&&connected(leader)?group:null;}
private void removeMember(Party group,UUID player){var remaining=group.members().stream().filter(member->!member.equals(player)).toList();if(remaining.stream().noneMatch(this::connected)){remove(group);return;}membership.remove(player);departed.remove(player);var updated=new Party(group.id(),group.leader(),remaining);for(var member:remaining)membership.put(member,updated);}
@@ -0,0 +1,28 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Fixed reviewed metadata and native level-I behavior for the basic-stick Knockback passive. */
public final class StickKnockback {
public static final String ID = "stick_knockback";
public PassiveDefinition definition() {
return new PassiveDefinition(ID, "Knockback", Map.of(Stat.STR, BigInteger.valueOf(12)), Set.of(),
BigInteger.ONE, List.of("Your bound basic attack stick carries native Knockback I.",
"Only positive accepted basic-stick damage can knock the target back."));
}
public static void apply(org.bukkit.entity.Player source, org.bukkit.entity.LivingEntity target) {
double radians = Math.toRadians(source.getLocation().getYaw());
double directionX = Math.sin(radians);
double directionZ = -Math.cos(radians);
if (!Double.isFinite(directionX) || !Double.isFinite(directionZ)) return;
boolean sprinting = source.isSprinting();
target.knockback(sprinting ? 2 : 1, directionX, directionZ);
if (sprinting) source.setSprinting(false);
}
}
@@ -0,0 +1,80 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publication settings for later daily catalogs, camp and champion attempts. */
public final class AdventureQuestSettings implements AutoCloseable {
public static final String DAILY_COUNT="adventure-daily-site-count", RADIUS="adventure-site-radius",
RESET_SECONDS="adventure-site-reset-seconds", 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(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) { }
public record Update(String key,String value,Map<String,String> values,long sequence) {
public Update { values=Map.copyOf(values); }
}
private final Path directory;
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-adventure-configuration"));
private volatile Map<String,String> effective;
private Map<String,String> persisted;
private long savedSequence,publishedSequence;
private boolean closed; private int pending;
private final CompletableFuture<Void> drained=new CompletableFuture<>();
public AdventureQuestSettings(Path directory,Map<String,String> initial) {
this.directory=Objects.requireNonNull(directory).toAbsolutePath();
var values=new LinkedHashMap<String,String>();
for(String key:KEYS)values.put(key,Objects.requireNonNull(initial.getOrDefault(key,DEFAULTS.get(key))));
parse(values);effective=Map.copyOf(values);persisted=effective;
}
public static List<String> keys(){return KEYS;}
public static String defaultValue(String key){return DEFAULTS.get(key);}
public String value(String key){return effective.get(key);}
public Rules rules(){return parse(effective);}
public static Rules parse(Map<String,String> values){
try{return new Rules(bounded(values.get(DAILY_COUNT),0,20),bounded(values.get(RADIUS),4,64),
bounded(values.get(RESET_SECONDS),0,600),bounded(values.get(CAMP_ENEMIES),4,64),
whole(values.get(CAMP_REWARD_XP)),whole(values.get(CAMP_REWARD_COINS)),
bounded(values.get(CHAMPION_HEALTH),1,2048),bounded(values.get(CHAMPION_DAMAGE),1,2048),
whole(values.get(CHAMPION_REWARD_XP)),whole(values.get(CHAMPION_REWARD_COINS)));}
catch(RuntimeException invalid){throw new IllegalArgumentException("Invalid adventure quest settings",invalid);}
}
private static int bounded(String raw,int minimum,int maximum){var value=new BigInteger(raw);if(value.compareTo(BigInteger.valueOf(minimum))<0||value.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return value.intValueExact();}
private static BigInteger whole(String raw){var value=new BigInteger(raw);if(value.signum()<0||value.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return value;}
public CompletionStage<Update> set(String key,String raw){
if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown adventure setting");
var preview=new LinkedHashMap<>(effective);preview.put(key,new BigInteger(raw).toString());var accepted=preview.get(key);parse(preview);
synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}
return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);parse(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();
}
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;parse(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
private void save(String key,String value)throws IOException{
Files.createDirectories(directory);var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");
try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){
if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(1_048_576);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> document=new LinkedHashMap<>();
if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(1_048_577);}if(bytes.length>1_048_576)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String savedKey))throw new IOException("Invalid key");document.put(savedKey,entry.getValue());}}
document.put(key,Integer.parseInt(value));byte[] bytes=yaml.dump(document).getBytes(StandardCharsets.UTF_8);Path temporary=Files.createTempFile(directory,".config-",".tmp");try{Files.write(temporary,bytes,StandardOpenOption.TRUNCATE_EXISTING);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){output.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);DurableFiles.forceDirectory(directory);}finally{Files.deleteIfExists(temporary);}
}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}
}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -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);
@@ -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");
@@ -0,0 +1,132 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.*;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publication Witch hard-mob XP pool. */
public final class HardMobXpSettings implements AutoCloseable {
public static final String KEY = "hard-mob-xp";
public static final String DEFAULT = "20";
private static final BigInteger MAXIMUM = BigInteger.valueOf(Integer.MAX_VALUE);
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String value, BigInteger pool, long sequence) { }
private final Path directory;
private final Replacement replacement;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-hard-mob-xp-configuration"));
private volatile String effective;
private String persisted;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public HardMobXpSettings(Path directory, String initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
HardMobXpSettings(Path directory, String initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
effective = canonical(initial); persisted = effective;
}
public static BigInteger parse(String raw) {
try {
var value = new BigInteger(raw);
if (value.signum() < 0 || value.compareTo(MAXIMUM) > 0) throw new IllegalArgumentException();
return value;
} catch (RuntimeException invalid) {
throw new IllegalArgumentException("Hard-mob XP must be a whole number from 0 through 2147483647", invalid);
}
}
private static String canonical(String raw) { return parse(raw).toString(); }
public String value() { return effective; }
public BigInteger pool() { return parse(effective); }
public CompletionStage<Update> set(String raw) {
String accepted = canonical(raw);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(accepted); persisted = accepted;
return new Update(accepted, parse(accepted), ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new CompletionException(failure instanceof IllegalArgumentException ? failure
: new IOException("Hard-mob XP save failed; effective value unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
}
}
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return false;
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
}
private void save(String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS))
throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(20);
options.setCodePointLimit(MAX_BYTES); var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes; try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
bytes = input.readNBytes(MAX_BYTES + 1);
}
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
document.put(key, entry.getValue());
}
}
}
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy", busy); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,57 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
import org.bukkit.entity.EntityType;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Atomic single-file durable authority for daily adventure quests. */
public final class YamlAdventureQuestRepository {
private final Path file;
public YamlAdventureQuestRepository(Path data){file=data.toAbsolutePath().resolve("adventure-quests.yml");}
public AdventureQuestState load()throws IOException{
if(Files.notExists(file))return AdventureQuestState.empty();
try{
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);if(number(root.get("schema-version"))!=1)throw new IllegalArgumentException();
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena));}
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());}
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());}
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,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(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);
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("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));}
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
private static String text(Object value){if(value instanceof String result)return result;throw new IllegalArgumentException();}
private static UUID uuid(Map<?,?> row,String key){return UUID.fromString(text(row.get(key)));}
private static double decimal(Map<?,?> row,String key){return Double.parseDouble(text(row.get(key)));}
private static int number(Object value){return Integer.parseInt(value.toString());}
}
@@ -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) {
@@ -8,9 +8,9 @@ import java.util.Objects;
import java.util.UUID;
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
BigDecimal restrictedSpacing) {
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger hardPool,
BigInteger bossPool, BigDecimal restrictedSpacing) {
public enum Target { PASSIVE, ORDINARY, HARD, BOSS, FANTASY_CHARACTER }
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
@@ -26,16 +26,26 @@ public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool,
}
public CombatRewardRules {
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool); Objects.requireNonNull(hardPool);
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || hardPool.signum() < 0 || bossPool.signum() < 0
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
}
}
public CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
BigDecimal restrictedSpacing) {
this(passivePool, ordinaryPool, BigInteger.valueOf(20), bossPool, restrictedSpacing);
}
public static CombatRewardRules defaults() {
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(20),
BigInteger.valueOf(100), BigDecimal.valueOf(3));
}
public CombatRewardRules withHardPool(BigInteger pool) {
return new CombatRewardRules(passivePool, ordinaryPool, pool, bossPool, restrictedSpacing);
}
public BigInteger pool(Target target, int defeatedLevel) {
@@ -47,6 +57,7 @@ public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool,
return switch (target) {
case PASSIVE -> passivePool;
case ORDINARY -> ordinaryPool;
case HARD -> hardPool;
case BOSS -> bossPool;
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
};
@@ -69,6 +69,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
private games.dmg.fantasystick.party.CombatExperience combatExperience;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
@@ -316,6 +321,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
games.dmg.fantasystick.party.CombatExperience experience) {
if (closed || hardMobXpSettings != null) throw new IllegalStateException("Hard-mob XP settings already attached or command closed");
hardMobXpSettings = Objects.requireNonNull(settings); combatExperience = Objects.requireNonNull(experience);
}
public void attachAdventureQuests(games.dmg.fantasystick.adventure.AdventureQuestCommands commands,
games.dmg.fantasystick.persistence.AdventureQuestSettings settings) {
if (closed || adventureQuestCommands != null) throw new IllegalStateException("Adventure quests already attached or command closed");
adventureQuestCommands = Objects.requireNonNull(commands); adventureQuestSettings = Objects.requireNonNull(settings);
}
public void attachDungeons(games.dmg.fantasystick.dungeon.DungeonCommands commands){if(closed||dungeonCommands!=null)throw new IllegalStateException("Dungeons already attached or command closed");dungeonCommands=Objects.requireNonNull(commands);}
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
public void attachOutOfCombatRecovery(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings settings,
OutOfCombatRecovery recovery) {
@@ -386,10 +402,20 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("adventure")) {
adventureQuestCommands.adventure(sender, args); return true;
}
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("You need fantasystick.admin to administer Fantasy Stick.");
sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");
return true;
}
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("adventure-site")) {
adventureQuestCommands.site(sender, args); return true;
}
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("quest-giver")) {
adventureQuestCommands.giver(sender, args); return true;
}
if(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;
@@ -691,6 +717,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (partyEconomySettings != null && games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])) { partyEconomyConfiguration(sender,args); return; }
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){gearConfiguration(sender,args);return;}
if (coinSettings != null && games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])) { coinConfiguration(sender,args); return; }
if (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
hardMobXpConfiguration(sender, args); return;
}
if (adventureQuestSettings != null && games.dmg.fantasystick.persistence.AdventureQuestSettings.keys().contains(args[1])) {
adventureQuestConfiguration(sender, args); return;
}
if (args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)) { sheetResourcePackConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
if (outOfCombatRecoverySettings != null
@@ -1319,6 +1351,40 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+coinSettings.value(key)+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void hardMobXpConfiguration(CommandSender sender, String[] args) {
String key = games.dmg.fantasystick.persistence.HardMobXpSettings.KEY;
if (args.length == 2) { sender.sendMessage(key + "=" + hardMobXpSettings.value()
+ " (applies to future uncaptured Witch defeats only)"); return; }
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HardMobXpSettings.Update> operation;
try { operation = hardMobXpSettings.set(args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Hard-mob XP is invalid; enter a whole number from 0 through 2147483647."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
boolean applied = failure == null && hardMobXpSettings.publish(saved);
if (applied) combatExperience.configure(combatExperience.rules().withHardPool(saved.pool()));
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) sender.sendMessage("Hard-mob XP save failed; the effective pool is unchanged.");
else if (!applied) sender.sendMessage("Saved request was superseded; effective " + key + "="
+ hardMobXpSettings.value() + ".");
else sender.sendMessage("Saved " + key + "=" + saved.value()
+ " for future uncaptured Witch defeats; existing progress and captured rewards are unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void adventureQuestConfiguration(CommandSender sender, String[] args) {
String key=args[1]; if(args.length==2){sender.sendMessage(key+"="+adventureQuestSettings.value(key)+" (applies to the owning later catalog, acceptance or encounter attempt)");return;}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdventureQuestSettings.Update> operation;
try{operation=adventureQuestSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Adventure quest setting is invalid; the effective value is unchanged.");return;}
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&adventureQuestSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Adventure quest configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+adventureQuestSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for later catalogs, acceptances or attempts; active state is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
}
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fantasystick config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
@@ -1361,10 +1427,19 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
// Return only explicit implemented routes; never fall back to Bukkit's broad default suggestions.
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
if (closed) return List.of();
if (adventureQuestCommands != null) {
if (args.length == 1 && !sender.hasPermission(PERMISSION)) return matching(List.of("adventure"), args[0]);
if (args.length > 1) {
var adventure = adventureQuestCommands.complete(sender,args); if(!adventure.isEmpty()) return adventure;
if(args[0].equals("adventure")||args[0].equals("adventure-site")||args[0].equals("quest-giver"))return List.of();
}
}
if (!sender.hasPermission(PERMISSION)) return List.of();
if(dungeonCommands!=null&&args.length>1){var dungeon=dungeonCommands.complete(sender,args);if(!dungeon.isEmpty())return dungeon;if(args[0].equals("dungeon"))return List.of();}
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "revive", "shop", "training-dummy")
: List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"), args[0]);
? List.of("adventure", "adventure-site", "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("shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
if(args.length==3&&args[0].equals("shop")&&args[1].equals("remove")&&armorers!=null)return matching(armorers.all().stream().map(value->value.id().toString()).toList(),args[2]);
if (args.length == 2 && args[0].equals("training-dummy")) return matching(
@@ -1387,6 +1462,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.addAll(games.dmg.fantasystick.persistence.PartyEconomySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.GearSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.CoinSettings.keys());
keys.add(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY);
keys.addAll(games.dmg.fantasystick.persistence.AdventureQuestSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys());
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY);
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
@@ -1420,6 +1497,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if(partyEconomySettings!=null&&games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])){String current=partyEconomySettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){String current=gearSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.GearSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if(coinSettings!=null&&games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])){String current=coinSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.CoinSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
String current = hardMobXpSettings.value(); String defaults = games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT;
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if(adventureQuestSettings!=null&&games.dmg.fantasystick.persistence.AdventureQuestSettings.keys().contains(args[1])){String current=adventureQuestSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if(sheetResourcePackSettings!=null&&(args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)))return matching(List.of(sheetResourcePackSettings.value(args[1])),args[2]);
if (outOfCombatRecoverySettings != null
&& games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys().contains(args[1])) {
@@ -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;
@@ -171,6 +172,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);
@@ -287,8 +293,8 @@ public final class EquipmentSessions implements AutoCloseable {
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
if(gear!=null&&!previous.gear().equals(saved.gear()))gear.install(attempt.player.getInventory(),saved,attempt.owner,attempt.session);
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
&& !refreshControls(attempt, session)) return false;
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns())
|| !previous.passives().equals(saved.passives())) && !refreshControls(attempt, session)) return false;
return true;
}
@@ -317,7 +323,7 @@ public final class EquipmentSessions implements AutoCloseable {
private boolean refreshControls(Attempt attempt, Active session) {
var refreshed = controls.create(attempt.record, attempt.owner, attempt.session);
boolean changed = false;
for (int slot = 2; slot < 9; slot++) {
for (int slot = 1; slot < 9; slot++) {
if (active.get(attempt.owner) != attempt || !current(session)) return false;
if (Objects.equals(attempt.player.getInventory().getItem(slot), refreshed[slot])) continue;
attempt.player.getInventory().setItem(slot, refreshed[slot]);
@@ -391,6 +397,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 +452,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;
}
+13
View File
@@ -56,9 +56,22 @@ boss-coin-drop-max: 50
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
passive-xp: 1
ordinary-xp: 10
hard-mob-xp: 20
boss-xp: 100
restricted-kill-spacing-blocks: 3
# UTC-daily shared adventure catalogs, camp attempts and per-unit completion rewards.
adventure-daily-site-count: 3
adventure-site-radius: 16
adventure-site-reset-seconds: 30
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
# Fireball defaults from the reviewed starter catalogue.
# Admin command: /fantasystick config <key> [value]
fireball-base-damage: 6
+3 -3
View File
@@ -7,9 +7,9 @@ description: Transferable characters, native fantasy health, recoverable equipme
author: dmg.games
commands:
fantasystick:
description: Issue or revive sheets, grant fantasy experience, recover sessions or configure Fantasy Stick.
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value] | shop <place [name...]|remove <shop-uuid>|list|restock> | party-registrar <place [name...]|remove <registrar-uuid>|list> | training-dummy <place [name...]|remove <dummy-uuid>|list>
permission: fantasystick.admin
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>
fantasyfeedback:
description: Read or change routine Fantasy Stick combat and ability feedback for your player account.
usage: /fantasyfeedback [on|off]
@@ -135,7 +135,7 @@ class AbilityEngineNativeTest {
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var effects = new AtomicInteger();
registry.register(new AbilityDefinition("test_focus", "Test Focus", Material.AMETHYST_SHARD,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
@@ -206,7 +206,7 @@ class AbilityEngineNativeTest {
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
AbilityDefinition.TargetPolicy targetPolicy, games.dmg.fantasystick.ability.AbilityEffect effect) {
return new AbilityDefinition(id, id, Material.AMETHYST_SHARD, List.of("Test definition"), "Use test input",
Map.of(), BigInteger.ONE, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), BigInteger.ONE, AbilityDefinition.Category.ATK, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), targetPolicy, effect);
}
@@ -36,16 +36,19 @@ class AbilityRegistryNativeTest {
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "UPPER", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("x", " ", Material.STONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {})),
() -> assertThrows(NullPointerException.class, () -> new AbilityDefinition("category", "Category", Material.STONE,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, null, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {})),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "low", Map.of(Stat.INT, BigInteger.valueOf(9)), Map.of(), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "negative", valid.requirements(), Map.of("range", BigDecimal.valueOf(-1)), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "infinite", valid.requirements(), Map.of("range", new BigDecimal("1E1000000")), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "cooldown", valid.requirements(), Map.of(), Duration.ofSeconds(-1))),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "long_cooldown", valid.requirements(), Map.of(), Duration.ofDays(366))),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("air", "Air", Material.AIR,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Activation.IMMEDIATE,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Category.ATK, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {}))
);
}
@@ -68,7 +71,7 @@ class AbilityRegistryNativeTest {
for (var policy : AbilityDefinition.TargetPolicy.values()) {
var base = definition("x");
assertDoesNotThrow(() -> new AbilityDefinition("a_" + activation.name().toLowerCase() + "_" + policy.name().toLowerCase(),
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), activation,
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), base.category(), activation,
base.gesture(), base.cooldown(), base.parameters(), policy, base.effect()));
}
}
@@ -76,7 +79,7 @@ class AbilityRegistryNativeTest {
static AbilityDefinition definition(String id) {
return new AbilityDefinition(id, "Test Bolt", Material.AMETHYST_SHARD, List.of("A focused test effect"),
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12), "duration", BigDecimal.valueOf(5)),
AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {});
@@ -85,6 +88,6 @@ class AbilityRegistryNativeTest {
private static AbilityDefinition copy(AbilityDefinition value, String id, Map<Stat, BigInteger> requirements,
Map<String, BigDecimal> parameters, Duration cooldown) {
return new AbilityDefinition(id, value.name(), value.icon(), value.lore(), value.instructions(), requirements,
value.pointCost(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
value.pointCost(), value.category(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
}
}
@@ -0,0 +1,327 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.*;
import org.bukkit.*;
import org.bukkit.entity.*;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdventureQuestNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@SuppressWarnings({"unchecked", "rawtypes"})
@Test void registeredCommandsGiverMenuAcceptanceCampCombatAndExactRewardUseConnectedPath() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"adventure-daily-site-count: 3\nadventure-site-reset-seconds: 0\nadventure-camp-enemies: 4\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.getName()).thenReturn("Adventure Realm");when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
var spawned=new ArrayList<LivingEntity>();
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=type==Villager.class?mock(Villager.class):mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
assertEquals(List.of("adventure-camp-enemies","adventure-camp-reward-coins","adventure-camp-reward-xp"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-camp"}));
f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-site-radius","20"});drainOne(f);
var questSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");assertEquals(20,questSettings.rules().radius());
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","camp","20","60","Dawn Camp"}); drainOne(f);
f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Guide"}); drainOne(f);
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
assertEquals(1,directory.snapshot().sites().size());assertEquals(1,directory.snapshot().givers().size());
assertEquals(List.of("camp","champion"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","c"}));
var current=directory.snapshot();var snapshotField=directory.getClass().getDeclaredField("snapshot");snapshotField.setAccessible(true);snapshotField.set(directory,new games.dmg.fantasystick.adventure.AdventureQuestState(current.sites(),current.givers(),null,current.quests(),current.unitClaims(),current.characterClaims(),current.rosters()));directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");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();
doReturn(false).when(actor.player).hasPermission("fantasystick.admin");f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});assertTrue(actor.messages.getLast().contains("Permission denied"));assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure",""}).isEmpty());assertEquals(originalCatalog,directory.snapshot().catalog());doReturn(true).when(actor.player).hasPermission("fantasystick.admin");f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm","extra"});assertTrue(actor.messages.getLast().contains("Usage"));assertEquals(originalCatalog,directory.snapshot().catalog());
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll"});assertTrue(actor.messages.getLast().contains("confirm"));assertEquals(originalCatalog,directory.snapshot().catalog());assertEquals(List.of("reroll"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","r"}));assertEquals(List.of("confirm"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","reroll","c"}));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.startsWith("Reset daily adventures:"));attempt++)drainOne(f);assertNotEquals(originalCatalog.revision(),directory.snapshot().catalog().revision());assertTrue(actor.messages.getLast().contains("Reset daily adventures")&&actor.messages.getLast().contains("expires"));
var stale=mock(InventoryClickEvent.class);when(stale.getWhoClicked()).thenReturn(actor.player);when(stale.getInventory()).thenReturn(menu);when(stale.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(stale);drainOne(f);assertTrue(actor.messages.getLast().contains("stale"));
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);menu=actor.inventories.getLast();var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(click);drainOne(f);
assertTrue(actor.messages.stream().anyMatch(message -> message.equals(
"Accepted Dawn Camp at Adventure Realm (12, 70, -8); expires "
+ directory.active().getFirst().expiresAt() + ".")));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure"});
assertTrue(actor.messages.getLast().startsWith("Dawn Camp at Adventure Realm (12, 70, -8); state="));
var siteRecord=directory.snapshot().sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);
var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);partyA.core.location.setWorld(actor.world);partyB.core.location.setWorld(actor.world);f.players.put(partyA.core.id,partyA.player);f.players.put(partyB.core.id,partyB.player);f.activate(partyA);f.activate(partyB);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=directory.snapshot().catalog();var siteId=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),siteId,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var actorCoins=storage.find(actor.core.record.id()).get().orElseThrow().coins();var partyACoins=storage.find(partyA.core.record.id()).get().orElseThrow().coins();var partyBCoins=storage.find(partyB.core.record.id()).get().orElseThrow().coins();
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<4;retry++)drainOne(f);
var enemies=spawned.stream().filter(entity->!(entity instanceof Villager)).toList();assertEquals(4,enemies.size());assertTrue(controller.hasActiveEncounter());
var enemy=enemies.getFirst();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();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.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"});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);
}
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();
assertTrue(accepted.startsWith("Accepted Monster Camp at Adventure Realm (12, -1, -9); expires "));
for (UUID internal : List.of(worldId, site.id(), directory.snapshot().catalog().revision(),
quest.id(), quest.unit(), actor.core.record.id())) {
assertFalse(accepted.contains(internal.toString()));
}
f.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
String inspected = actor.messages.getLast();
assertTrue(inspected.startsWith("Monster Camp at Adventure Realm (12, -1, -9); state="));
assertFalse(inspected.contains(worldId.toString()));
assertFalse(inspected.contains(site.id().toString()));
assertFalse(inspected.contains(quest.id().toString()));
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
f.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
assertTrue(actor.messages.getLast().startsWith("Monster Camp at unavailable world (12, -1, -9); state="));
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()));
}
}
private static void drainOne(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}
}
@@ -28,6 +28,7 @@ class BasicAttackNativeTest {
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
var target = mock(org.bukkit.entity.LivingEntity.class);
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
@@ -35,6 +36,7 @@ class BasicAttackNativeTest {
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
event.setCancelled(cancelled.get());
if (zero.get()) event.setDamage(0);
basic.acceptedDamage(event);
return null;
}).when(target).damage(anyDouble(), same(a.player));
@@ -43,12 +45,13 @@ class BasicAttackNativeTest {
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
() -> sessions.current(active));
basic.accept(input); // Cancelled: no cadence spent.
cancelled.set(false);
basic.accept(input); // Accepted at t=0.
cancelled.set(false); zero.set(true);
basic.accept(input); // Zero final damage: no cadence spent.
zero.set(false); basic.accept(input); // Accepted at t=0.
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
verify(target, times(2)).damage(1.0, a.player);
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
verify(target, times(3)).damage(1.0, a.player);
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
verify(target, times(4)).damage(1.0, a.player);
a.core.inventory.setItemInOffHand(null); f.changed(a);
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
verifyNoMoreInteractions(target);
@@ -88,6 +91,32 @@ class BasicAttackNativeTest {
}
}
@Test void packagedBasicStickUsesBasePlusStrengthModifierAcrossApprovedBoundaries() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
int[] strengths = {0, 9, 10, 11, 12, 13, 14, 15};
double[] expected = {1, 1, 1, 1, 2, 2, 3, 3};
for (int index = 0; index < strengths.length; index++) {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
withStrength(data, actor, java.math.BigInteger.valueOf(strengths[index]));
f.players.put(actor.core.id, actor.player); f.activate(actor);
actor.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled()); verify(target).damage(expected[index], actor.player);
actor.core.inventory.setItemInOffHand(null); f.changed(actor); f.awaitCharacterAndReceipt(actor);
}
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
withStrength(data, actor, java.math.BigInteger.TEN.pow(1000));
f.players.put(actor.core.id, actor.player); f.activate(actor);
actor.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
verify(target).damage(Double.MAX_VALUE, actor.player);
}
}
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -106,7 +135,20 @@ class BasicAttackNativeTest {
}
}
private static final class MutableClock extends Clock {
private static void withStrength(Path data, FantasyStickLifecycleNativeTest.Actor actor,
java.math.BigInteger strength) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, strength);
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(),
old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(),
false, null, null, old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()),
old.revision());
}
static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
void advance(java.time.Duration duration) { now = now.plus(duration); }
@@ -0,0 +1,48 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.*;
import org.bukkit.*;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.*;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionQuestNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@SuppressWarnings({"unchecked", "rawtypes"})
@Test void registeredChampionOfferNativeEncounterBossBarAndExactRewardUseConnectedPath() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"adventure-daily-site-count: 1\nadventure-site-reset-seconds: 0\n"+
"adventure-champion-health: 100\nadventure-champion-damage: 8\n"+
"adventure-champion-reward-xp: 150\nadventure-champion-reward-coins: 40\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
var spawned=new ArrayList<LivingEntity>();var maxHealth=mock(AttributeInstance.class);when(maxHealth.getValue()).thenReturn(100.0);var attack=mock(AttributeInstance.class);
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(entity.getHealth()).thenReturn(100.0);if(entity instanceof Vindicator){when(entity.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maxHealth);when(entity.getAttribute(Attribute.ATTACK_DAMAGE)).thenReturn(attack);}((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
assertEquals(List.of("adventure-champion-damage","adventure-champion-health","adventure-champion-reward-coins","adventure-champion-reward-xp"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-champion"}));
assertEquals(List.of("champion"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","ch"}));
f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","9"});drainOne(f);assertEquals(List.of("8","9"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage",""}));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","champion","20","60","Summit"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Guide"});drainOne(f);
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var 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 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);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();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().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();
}
}
private static void drainOne(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}
}
@@ -334,26 +334,60 @@ class CharacterBookNativeTest {
assertTrue(pages.get(2).getString().contains("3: No Ability"));
int index = firstPageStarting(pages, "Ability index");
assertTrue(index > 2);
var indexedNames = new java.util.ArrayList<String>();
var categorized = new java.util.LinkedHashMap<String, java.util.List<String>>();
var indexPageByAbility = new java.util.HashMap<String, Integer>();
int partyPage = firstPageStarting(pages, "Party");
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Ability index")) {
indexedNames.add(line);
var jump = changePage(pages.get(page), line);
assertNotNull(jump, line);
var lines = pages.get(page).getString().split("\\n");
assertTrue(lines.length >= 2); String category = lines[1];
assertTrue(java.util.List.of("ATK", "BUF", "HEAL").contains(category));
var names = categorized.computeIfAbsent(category, ignored -> new java.util.ArrayList<>());
for (int line = 2; line < lines.length; line++) if (!lines[line].isBlank()) {
names.add(lines[line]); indexPageByAbility.put(lines[line], page);
var jump = changePage(pages.get(page), lines[line]);
assertNotNull(jump, lines[line]);
assertTrue(jump.page() - 1 < partyPage, "Ability details must precede party controls");
}
}
var sortedNames = indexedNames.stream().sorted().toList();
assertEquals(sortedNames, indexedNames);
assertEquals(20, indexedNames.size());
assertEquals(java.util.List.of("ATK", "BUF", "HEAL"), new java.util.ArrayList<>(categorized.keySet()));
assertEquals(java.util.List.of("Chain Lightning", "Fireball", "Frost Nova", "Frost Shard", "Great Sword",
"Hex", "Longbow", "Meteor", "Shield Bash"), categorized.get("ATK"));
assertEquals(java.util.List.of("Guardian Bond", "Inspire", "Purify", "Quickstep", "Rallying Cry",
"Stone Skin", "War Banner"), categorized.get("BUF"));
assertEquals(java.util.List.of("Mending Light", "Revive", "Sanctuary", "Second Wind"), categorized.get("HEAL"));
assertEquals(20, categorized.values().stream().mapToInt(java.util.List::size).sum());
var detailStartByAbility = new java.util.HashMap<String, Integer>();
for (var entry : indexPageByAbility.entrySet()) {
var jump = changePage(pages.get(entry.getValue()), entry.getKey()); assertNotNull(jump);
detailStartByAbility.put(entry.getKey(), jump.page() - 1);
}
int passiveIndex = firstPageStarting(pages, "Passive index");
int firstPassiveDetail = Integer.MAX_VALUE;
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Passive index")) {
var jump = changePage(pages.get(page), line); if (jump != null) firstPassiveDetail = Math.min(firstPassiveDetail, jump.page() - 1);
}
}
for (var entry : indexPageByAbility.entrySet()) {
int detailPage = detailStartByAbility.get(entry.getKey());
assertTrue(pages.get(detailPage).getString().startsWith(entry.getKey() + "\n"));
assertTrue(pages.get(detailPage).getString().split("\\n")[1].matches("ATK|BUF|HEAL"));
int end = firstPassiveDetail;
for (int other : detailStartByAbility.values()) if (other > detailPage) end = Math.min(end, other);
var backPages = new java.util.ArrayList<Integer>();
for (int candidate = detailPage; candidate < end; candidate++)
if (changePage(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
var back = changePage(pages.get(end - 1), "[Back to index]"); assertNotNull(back);
assertEquals(entry.getValue() + 1, back.page(), entry.getKey());
}
var fireballJump = changePage(pages.get(index), "Fireball");
assertNotNull(fireballJump);
var detail = pages.get(fireballJump.page() - 1);
assertTrue(detail.getString().startsWith("Fireball\nINT 12\n[Learn]"), detail.getString());
assertTrue(detail.getString().startsWith("Fireball\nATK\nINT 12\n[Learn]"), detail.getString());
assertFalse(text(a.books.getLast()).contains("Cost:"));
assertFalse(text(a.books.getLast()).contains("Requires:"));
assertTrue(text(a.books.getLast()).contains("Shield Bash\nSTR 12, CON 12"));
assertTrue(text(a.books.getLast()).contains("Shield Bash\nATK\nSTR 12, CON 12"));
assertFalse(detail.getString().contains("Icon:"));
assertFalse(text(a.books.getLast()).contains("Assign ability"));
assertFalse(text(a.books.getLast()).contains("[3]"));
@@ -366,7 +400,29 @@ class CharacterBookNativeTest {
assertTrue(refreshed.get(2).getString().contains("3: Fireball"));
var activeJump = changePage(refreshed.get(2), "Fireball");
assertNotNull(activeJump);
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nINT 12\n[Unlearn]"));
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nATK\nINT 12\n[Unlearn]"));
}
}
@Test void mixedCategorySlotsAndUnknownIdsRemainUnrestrictedAndUnchanged() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var mixed = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(),
java.util.Set.of("fireball", "quickstep", "second_wind", "unknown_saved"),
java.util.List.of("fireball", "quickstep", "second_wind", "unknown_saved", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(mixed, old.revision()); f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
var pages = nativePages(a); var active = pages.get(firstPageStarting(pages, "Active abilities"));
assertTrue(active.getString().contains("3: Fireball")); assertTrue(active.getString().contains("4: Quickstep"));
assertTrue(active.getString().contains("5: Second Wind")); assertTrue(active.getString().contains("6: unknown_saved"));
for (String ability : java.util.List.of("Fireball", "Quickstep", "Second Wind")) {
var jump = changePage(active, ability); assertNotNull(jump, ability);
assertTrue(pages.get(jump.page() - 1).getString().startsWith(ability + "\n"));
}
assertEquals(mixed, characters(f).find(mixed.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@@ -32,6 +32,48 @@ class CombatExperienceNativeTest {
games.dmg.fantasystick.party.CombatExperience.coinTarget(wither));
assertEquals(games.dmg.fantasystick.progression.CombatRewardRules.Target.ORDINARY,
games.dmg.fantasystick.party.CombatExperience.classify(wither));
var witch = type(EntityType.WITCH);
assertEquals(games.dmg.fantasystick.progression.CombatRewardRules.Target.HARD,
games.dmg.fantasystick.party.CombatExperience.classify(witch));
assertEquals(games.dmg.fantasystick.progression.CoinRules.Target.HOSTILE,
games.dmg.fantasystick.party.CombatExperience.coinTarget(witch));
}
@Test void packagedWitchPoolFlowsThroughExactSoloAndPartyAllocationWithOrdinaryCoins() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var solo = new FantasyStickLifecycleNativeTest.Actor(data);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
var c = new FantasyStickLifecycleNativeTest.Actor(data);
for (var actor : java.util.List.of(solo, a, b, c)) { f.players.put(actor.core.id, actor.player); f.activate(actor); }
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
assertTrue(parties.accept(c.core.id, parties.invite(a.core.id, party.id(), c.core.id).orElseThrow().id()));
var beforeCoins = record(f, solo).coins().add(record(f, a).coins())
.add(record(f, b).coins()).add(record(f, c).coins());
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var witch = mob(EntityType.WITCH, new Location(world, 10, 64, 0));
hit(f, solo.player, witch, false);
var projectile = PartyCombatNativeTest.projectile(f, a.player); hit(f, projectile, witch, false);
die(f, witch); settle(f); settle(f); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, solo).experience());
var partyXp = java.util.List.of(record(f, a).experience(), record(f, b).experience(), record(f, c).experience());
assertEquals(java.math.BigInteger.TEN,
partyXp.stream().reduce(java.math.BigInteger.ZERO, java.math.BigInteger::add));
assertEquals(java.util.List.of(java.math.BigInteger.valueOf(3), java.math.BigInteger.valueOf(3),
java.math.BigInteger.valueOf(4)), partyXp.stream().sorted().toList());
var afterCoins = record(f, solo).coins().add(record(f, a).coins())
.add(record(f, b).coins()).add(record(f, c).coins());
var delta = afterCoins.subtract(beforeCoins);
assertTrue(delta.compareTo(java.math.BigInteger.ONE) >= 0 && delta.compareTo(java.math.BigInteger.valueOf(5)) <= 0,
"Witch uses one ordinary hostile coin pool");
assertTrue(solo.messages.stream().anyMatch(message -> message.contains("10") && message.contains("XP")));
solo.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, solo);
assertTrue(PartyBookNativeTest.text(solo).contains("XP: 10/100"));
die(f, witch); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, solo).experience(), "Duplicate death callbacks are consumed once");
}
}
@Test void packagedBasicAttackToBossDeathPersistsLevelAndUpdatesBookFeedbackExactlyOnce() throws Exception {
@@ -197,13 +239,13 @@ class CombatExperienceNativeTest {
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
failed.completeExceptionally(new java.io.IOException("simulated write failure"));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(), java.math.BigInteger.ONE,
original.stats(), original.level(), java.math.BigInteger.TEN, original.unspentPoints(), original.health(),
original.stats(), original.level(), java.math.BigInteger.valueOf(20), original.unspentPoints(), original.health(),
original.learned(), original.loadout(), original.cooldowns(), original.restrictedKills(), false, null, null);
var success = java.util.concurrent.CompletableFuture.completedFuture(
new games.dmg.fantasystick.persistence.CharacterStorage.CombatAward(
games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED, java.util.Optional.of(saved), 0));
var writes = new java.util.concurrent.atomic.AtomicInteger();
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.TEN), any(),
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.valueOf(20)), any(),
same(progression), any(), any())).thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
@@ -211,13 +253,14 @@ class CombatExperienceNativeTest {
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
ignored -> attacker, failures::add);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
var target = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
assertEquals(1, failures.size());
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());
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
@@ -261,6 +304,40 @@ class CombatExperienceNativeTest {
}
}
@Test void hardMobXpCommandPersistsBeforePublishingAndAffectsOnlyFutureWitchDefeats() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hard-mob-xp: 25\nunrelated: retained\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = true;
assertEquals(java.util.List.of("hard-mob-xp"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard"}));
assertEquals(java.util.List.of("20", "25"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp"});
assertTrue(sender.messages.getLast().contains("=25"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", "2147483648"});
assertTrue(sender.messages.getLast().contains("invalid"));
var service = (games.dmg.fantasystick.party.CombatExperience)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "combatExperience");
assertEquals(java.math.BigInteger.valueOf(25), service.rules().hardPool());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", "30"});
Runnable response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response);
assertEquals(java.math.BigInteger.valueOf(25), service.rules().hardPool(), "Durable write precedes publication");
response.run(); assertEquals(java.math.BigInteger.valueOf(30), service.rules().hardPool());
var yaml = (java.util.Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(
java.nio.file.Files.readString(data.resolve("config.yml")));
assertEquals("retained", yaml.get("unrelated")); assertEquals("30", String.valueOf(yaml.get("hard-mob-xp")));
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player); f.activate(actor);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var witch = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
hit(f, actor.player, witch, false); die(f, witch); settle(f);
assertEquals(java.math.BigInteger.valueOf(30), record(f, actor).experience());
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard"}).isEmpty());
}
}
@Test void interceptedFantasyDefeatUsesVictimLevelPoolAndCancelledDamageCreatesNoCredit() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -0,0 +1,87 @@
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(); }
@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 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();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));
for(int attempt=0;attempt<50&&!adventures.active().isEmpty();attempt++){drain(f);Thread.sleep(5);}var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated());assertTrue(adventures.active().isEmpty());var after=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(before.experience().add(BigInteger.valueOf(80)),after.experience());assertEquals(before.coins().add(BigInteger.valueOf(15)),after.coins());var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(80),partyAAfter.experience().subtract(partyABefore.experience()).add(partyBAfter.experience().subtract(partyBBefore.experience())));assertEquals(BigInteger.valueOf(15),partyAAfter.coins().subtract(partyABefore.coins()).add(partyBAfter.coins().subtract(partyBBefore.coins())));assertTrue(partyAAfter.experience().compareTo(partyABefore.experience())>0&&partyBAfter.experience().compareTo(partyBBefore.experience())>0);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"));assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated());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());command(f,actor,"dungeon","open",dungeon.id().toString());var resetCatalog=adventures.snapshot().catalog();assertTrue(resetCatalog.sites().contains(dungeon.id()));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"));assertFalse(adventures.active().isEmpty());command(f,actor,"dungeon","edit","start",dungeon.id().toString(),"confirm");await(f,()->dungeonController.editing(actor.core.id).isPresent(),actor.messages);assertTrue(adventures.active().isEmpty());assertFalse(dungeons.find(dungeon.id()).orElseThrow().enabled());
}
}
@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());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);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("config","experience","give","inspect","party-registrar","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("adventure-site","config","dungeon","experience","give","inspect","party-registrar","quest-giver","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
@@ -94,7 +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("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
@@ -109,7 +109,8 @@ class EquipmentAdminCommandNativeTest {
assertEquals(List.of("true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "t"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "true", ""}).isEmpty());
sender.allowed = false;
for (String[] args : List.of(new String[]{""}, new String[]{"inspect", ""}, new String[]{"config", ""},
assertEquals(List.of("adventure"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
for (String[] args : List.of(new String[]{"inspect", ""}, new String[]{"config", ""},
new String[]{"config", "adventure-mode", ""})) {
assertTrue(f.command.tabComplete(sender.port, "fantasystick", args).isEmpty());
}
@@ -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());
}
}
}
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(13, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor-market/menu refresh and session particles");
assertEquals(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);
@@ -904,6 +904,13 @@ class FantasyStickLifecycleNativeTest {
@Test void invalidCombatRewardConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
int scenario = 0;
for (String row : List.of("passive-xp: -1", "ordinary-xp: 1.5", "boss-xp: nope",
"hard-mob-xp: -1", "hard-mob-xp: 2147483648", "hard-mob-xp: 1.5",
"adventure-daily-site-count: 21", "adventure-site-radius: 3",
"adventure-site-reset-seconds: 601", "adventure-camp-enemies: 3",
"adventure-camp-reward-xp: -1", "adventure-camp-reward-coins: 2147483648",
"adventure-champion-health: 0", "adventure-champion-health: 2049",
"adventure-champion-damage: 0", "adventure-champion-damage: 2049",
"adventure-champion-reward-xp: -1", "adventure-champion-reward-coins: 2147483648",
"restricted-kill-spacing-blocks: -0.1", "restricted-kill-spacing-blocks: 1e1000000")) {
var directory = data.resolve("invalid-reward-" + scenario++); java.nio.file.Files.createDirectories(directory);
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
@@ -1114,6 +1121,7 @@ class FantasyStickLifecycleNativeTest {
final List<Runnable> pulses = new ArrayList<>();
final List<Runnable> delayed = new ArrayList<>();
final List<Long> delayedTicks = new ArrayList<>();
final List<org.bukkit.boss.BossBar> bossBars = new ArrayList<>();
com.comphenix.protocol.events.PacketListener packetListener;
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
final Thread main = Thread.currentThread();
@@ -1206,6 +1214,7 @@ class FantasyStickLifecycleNativeTest {
case "createInventory" -> args[1] instanceof Integer size
? new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], size, (net.kyori.adventure.text.Component) args[2])
: new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], (org.bukkit.event.inventory.InventoryType) args[1], (net.kyori.adventure.text.Component) args[2]);
case "createBossBar" -> { var bar=mock(org.bukkit.boss.BossBar.class);bossBars.add(bar);yield bar; }
case "addRecipe" -> {
order.add("recipe");
if ("recipe".equals(failRegistrationAt)) throw new IllegalStateException("Recipe registration failed");
@@ -1288,7 +1297,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(actor.core.marker().endsWith("|PACKED"));
}
private void captureWorkers(JavaPlugin instance) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies")) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies",
"hardMobXpSettings", "adventureQuestSettings", "adventureQuests")) {
var store = field(instance, name);
if (store != null) {
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
@@ -1322,10 +1332,12 @@ class FantasyStickLifecycleNativeTest {
// Location holds a WeakReference: the fixture must model the server's world ownership.
final org.bukkit.World world = mock(org.bukkit.World.class);
final List<String> messages = new ArrayList<>();
final List<net.kyori.adventure.text.Component> messageComponents = new ArrayList<>();
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
final List<org.bukkit.inventory.Inventory> inventories = new ArrayList<>();
int inventoryCloses;
final List<Integer> particles = new ArrayList<>();
final List<Object[]> particleCalls = new ArrayList<>();
final List<org.bukkit.Sound> sounds = new ArrayList<>();
final List<net.kyori.adventure.title.Title> titles = new ArrayList<>();
final List<Object[]> resourcePacks = new ArrayList<>();
@@ -1337,6 +1349,7 @@ class FantasyStickLifecycleNativeTest {
healthAttributes.getInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
boolean healthScaled;
boolean sprinting;
double healthScale = 20;
double absorption;
Runnable scaleMutation = () -> {};
@@ -1400,12 +1413,21 @@ class FantasyStickLifecycleNativeTest {
yield null;
}
case "isSneaking", "isInvisible" -> 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 "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; }
@@ -346,7 +346,7 @@ class LongbowNativeTest {
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("config", "experience", "give", "inspect", "party-registrar", "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());
@@ -35,6 +35,28 @@ class QuickerRegenerationNativeTest {
assertTrue(active > 0 && active < passive && passive < activeIndex && activeIndex < passiveIndex && passiveIndex < party);
assertTrue(pages.get(passive).getString().contains("1: No Passive"));
assertTrue(pages.get(passiveIndex).getString().replace("\n", "").contains("Quicker Regeneration I"));
var indexPageByPassive = new java.util.HashMap<String, Integer>();
var detailStartByPassive = new java.util.HashMap<String, Integer>();
var passiveRegistry = (games.dmg.fantasystick.passive.PassiveRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
for (var definition : passiveRegistry.definitions()) {
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) {
var target = changePageTo(pages.get(page), pages, definition.name());
if (target != null) { indexPageByPassive.put(definition.name(), page);
detailStartByPassive.put(definition.name(), target.page() - 1); break; }
}
}
assertEquals(passiveRegistry.definitions().size(), detailStartByPassive.size());
for (var entry : detailStartByPassive.entrySet()) {
int end = party;
for (int other : detailStartByPassive.values()) if (other > entry.getValue()) end = Math.min(end, other);
var backPages = new java.util.ArrayList<Integer>();
for (int candidate = entry.getValue(); candidate < end; candidate++)
if (changePageNamed(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
assertEquals(indexPageByPassive.get(entry.getKey()) + 1,
changePageNamed(pages.get(end - 1), "[Back to index]").page(), entry.getKey());
}
var jump = changePageTo(pages.get(passiveIndex), pages, "Quicker Regeneration I"); assertNotNull(jump);
String detail = pages.get(jump.page() - 1).getString();
assertTrue(detail.replaceAll("\\s+", "").startsWith("QuickerRegenerationICON12,WIS12[Learn]"), detail);
@@ -74,6 +96,50 @@ class QuickerRegenerationNativeTest {
}
}
@Test void multiPagePassiveIndexAndWrappedDetailsReturnToTheirExactContainingPage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.passive.PassiveRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
for (int index = 0; index < 12; index++) {
String suffix = String.format(java.util.Locale.ROOT, "%02d", index);
registry.register(new games.dmg.fantasystick.passive.PassiveDefinition("synthetic_" + suffix,
"Synthetic Passive " + suffix, java.util.Map.of(), java.util.Set.of(), BigInteger.ONE,
List.of(index == 0 ? "Wrapped detail ".repeat(80) : "Synthetic passive detail.")));
}
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
var before = storage(f).find(a.core.record.id()).get().orElseThrow(); PartyBookNativeTest.open(f, a);
var pages = nativePages(a); assertTrue(pages.size() <= 100);
int passiveIndex = first(pages, "Passive index"), party = first(pages, "Party");
var indexPageByPassive = new java.util.HashMap<String, Integer>();
var detailStartByPassive = new java.util.HashMap<String, Integer>();
int indexPages = 0;
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) indexPages++;
for (var definition : registry.definitions()) {
for (int page = passiveIndex; page < passiveIndex + indexPages; page++) {
var target = changePageTo(pages.get(page), pages, definition.name());
if (target != null) { indexPageByPassive.put(definition.name(), page);
detailStartByPassive.put(definition.name(), target.page() - 1); break; }
}
}
assertTrue(indexPages >= 2); assertEquals(registry.definitions().size(), detailStartByPassive.size());
boolean wrapped = false;
for (var entry : detailStartByPassive.entrySet()) {
int end = party;
for (int other : detailStartByPassive.values()) if (other > entry.getValue()) end = Math.min(end, other);
wrapped |= end - entry.getValue() > 1;
var backPages = new java.util.ArrayList<Integer>();
for (int candidate = entry.getValue(); candidate < end; candidate++)
if (changePageNamed(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
assertEquals(indexPageByPassive.get(entry.getKey()) + 1,
changePageNamed(pages.get(end - 1), "[Back to index]").page(), entry.getKey());
}
assertTrue(wrapped, "At least one passive detail must span native pages");
assertEquals(before, storage(f).find(before.id()).get().orElseThrow());
}
}
@Test void unknownFullPassiveSlotsRemainVisibleAndRefuseBeforeSpending() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -144,6 +210,15 @@ class QuickerRegenerationNativeTest {
}
return null;
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageNamed(
net.minecraft.network.chat.Component component, String label) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page) return page;
for (var child : component.getSiblings()) {
var found = changePageNamed(child, label); if (found != null) return found;
}
return null;
}
private static net.minecraft.network.chat.ClickEvent.Custom custom(List<net.minecraft.network.chat.Component> pages, String id) {
for (var page : pages) { var found = custom(page, id); if (found != null) return found; }
throw new AssertionError("Missing custom action " + id);
@@ -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("config", "experience", "give", "inspect", "party-registrar", "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());
@@ -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("config", "experience", "give", "inspect", "party-registrar", "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());
@@ -0,0 +1,192 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.List;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StickKnockbackNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookLearningRefreshesAuthenticStickAcceptedAttackTransfersAndUnlearns() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.passive.PassiveRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
var definition = registry.find("stick_knockback").orElseThrow();
assertEquals("Knockback", definition.name()); assertEquals(BigInteger.valueOf(12),
definition.requirements().get(games.dmg.fantasystick.progression.Stat.STR));
assertEquals(BigInteger.ONE, definition.pointCost()); assertTrue(definition.prerequisites().isEmpty());
var ineligible = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, ineligible, 11, List.of("", "", ""));
f.players.put(ineligible.core.id, ineligible.player); f.activate(ineligible); PartyBookNativeTest.open(f, ineligible);
var refusedPages = pages(ineligible); var refusedDetail = changePageTo(refusedPages, "Knockback"); assertNotNull(refusedDetail);
String refused = refusedPages.get(refusedDetail.page() - 1).getString().replaceAll("\\s+", "");
assertTrue(refused.contains("STR12")); assertNull(findCustom(refusedPages.get(refusedDetail.page() - 1),
"fantasystick:passive_unlock"));
ineligible.core.inventory.setItemInOffHand(null); f.changed(ineligible); f.awaitCharacterAndReceipt(ineligible);
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, 12, List.of("", "", ""));
f.players.put(actor.core.id, actor.player); f.activate(actor); PartyBookNativeTest.open(f, actor);
var pages = pages(actor); var detail = changePageTo(pages, "Knockback"); assertNotNull(detail);
assertTrue(pages.get(detail.page() - 1).getString().replaceAll("\\s+", "").contains("STR12[Learn]"));
PartyBookNativeTest.click(f, actor, custom(pages.get(detail.page() - 1), "fantasystick:passive_unlock"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var learned = storage(f).find(actor.core.record.id()).get().orElseThrow();
assertEquals(List.of("stick_knockback", "", ""), learned.passives());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
assertEquals(1, actor.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
actor.core.inventory.getInventory().setSelectedSlot(1);
var target = target();
doAnswer(call -> { accepted(f, actor, target, (Double) call.getArgument(0), false, false); return null; })
.when(target).damage(anyDouble(), same(actor.player));
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
verify(target).knockback(eq(1.0), anyDouble(), anyDouble());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
actor.core.inventory.setItemInOffHand(null); f.changed(actor); f.awaitCharacterAndReceipt(actor);
var holder = new FantasyStickLifecycleNativeTest.Actor(data);
holder.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(learned));
f.players.put(holder.core.id, holder.player); f.activate(holder);
assertEquals(1, holder.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
PartyBookNativeTest.open(f, holder);
PartyBookNativeTest.click(f, holder, custom(pages(holder), "fantasystick:passive_unlearn"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage(f).find(learned.id()).get().orElseThrow();
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertEquals(0, holder.core.inventory.getItem(1).getEnchantmentLevel(Enchantment.KNOCKBACK));
holder.core.inventory.setItemInOffHand(null); f.changed(holder); f.awaitCharacterAndReceipt(holder);
assertTrue(holder.core.inventory.getItem(1) == null || holder.core.inventory.getItem(1).isEmpty());
}
}
@Test void cancelledZeroStaleEarlyAndTrainingHitsCannotKnockBackWhileNativeSprintStrengthIsRetained() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, 12,
List.of("stick_knockback", "", ""));
f.players.put(actor.core.id, actor.player); f.activate(actor);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var active = sessions.active(actor.core.id).orElseThrow();
var clock = new BasicAttackNativeTest.MutableClock(java.time.Instant.parse("2026-09-21T00:00:00Z"));
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
var target = target();
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
doAnswer(call -> {
var event = accepted(f, actor, target, (Double) call.getArgument(0), cancelled.get(), zero.get());
basic.acceptedDamage(event); return null;
}).when(target).damage(anyDouble(), same(actor.player));
var input = new games.dmg.fantasystick.session.ControlInput(actor.player, active, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> sessions.current(active));
basic.accept(input); verify(target, never()).knockback(anyDouble(), anyDouble(), anyDouble());
cancelled.set(false); zero.set(true); basic.accept(input);
verify(target, never()).knockback(anyDouble(), anyDouble(), anyDouble());
zero.set(false); actor.sprinting = true;
basic.accept(input); verify(target).knockback(eq(2.0), anyDouble(), anyDouble()); assertFalse(actor.sprinting);
basic.accept(input); verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
actor.core.inventory.setItemInOffHand(null); f.changed(actor);
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
verify(target, times(1)).knockback(anyDouble(), anyDouble(), anyDouble());
var current = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, current, 12,
List.of("stick_knockback", "", "")); f.players.put(current.core.id, current.player); f.activate(current);
var currentSession = sessions.active(current.core.id).orElseThrow();
var dummy = target();
var training = new games.dmg.fantasystick.training.TrainingDamage() {
@Override public java.util.Optional<java.math.BigDecimal> apply(org.bukkit.entity.Player source,
LivingEntity ignored, Runnable nativeDamage) {
return java.util.Optional.of(java.math.BigDecimal.ONE);
}
@Override public boolean target(org.bukkit.entity.Entity entity) { return entity == dummy; }
};
var measured = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock, null, training);
measured.accept(new games.dmg.fantasystick.session.ControlInput(current.player, currentSession, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, dummy, 0, 1,
() -> sessions.current(currentSession)));
verify(dummy, never()).knockback(anyDouble(), anyDouble(), anyDouble());
}
}
private static LivingEntity target() {
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return target;
}
private static EntityDamageByEntityEvent accepted(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor, LivingEntity target, double amount, boolean cancelled, boolean zero) {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(actor.player); when(source.getDirectEntity()).thenReturn(actor.player);
var event = new EntityDamageByEntityEvent(actor.player, target, DamageCause.ENTITY_ATTACK, source, amount);
event.setCancelled(cancelled); if (zero) event.setDamage(0); f.dispatcher.accept(event); return event;
}
private static void prepare(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength,
List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(strength));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), BigInteger.valueOf(40), old.health(),
old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null,
old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()), old.revision());
}
private static games.dmg.fantasystick.persistence.CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static List<net.minecraft.network.chat.Component> pages(FantasyStickLifecycleNativeTest.Actor actor) {
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT).pages().stream().map(page -> page.raw()).toList();
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(
List<net.minecraft.network.chat.Component> pages, String heading) {
for (var page : pages) { var found = changePageTo(page, pages, heading); if (found != null) return found; }
return null;
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(net.minecraft.network.chat.Component component,
List<net.minecraft.network.chat.Component> pages, String heading) {
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page
&& page.page() > 0 && page.page() <= pages.size()
&& pages.get(page.page() - 1).getString().replaceAll("\\s+", "")
.startsWith(heading.replaceAll("\\s+", ""))) return page;
for (var child : component.getSiblings()) { var found = changePageTo(child, pages, heading); if (found != null) return found; }
return null;
}
private static net.minecraft.network.chat.ClickEvent.Custom custom(net.minecraft.network.chat.Component component, String id) {
var found = findCustom(component, id);
if (found == null) throw new AssertionError("Missing custom action " + id);
return found;
}
private static net.minecraft.network.chat.ClickEvent.Custom custom(List<net.minecraft.network.chat.Component> pages, String id) {
for (var page : pages) { var found = findCustom(page, id); if (found != null) return found; }
throw new AssertionError("Missing custom action " + id);
}
private static net.minecraft.network.chat.ClickEvent.Custom findCustom(
net.minecraft.network.chat.Component component, String id) {
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom action
&& action.id().toString().equals(id)) return action;
for (var child : component.getSiblings()) { var found = findCustom(child, id); if (found != null) return found; }
return null;
}
}
@@ -30,12 +30,12 @@ class DistributionTest {
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("fantasystick.admin", metadata.getCommands().get("fantasystick").get("permission"));
assertNull(metadata.getCommands().get("fantasystick").get("permission"),
"Player adventure inspection must reach command-level authorization");
assertNull(metadata.getCommands().get("fantasyfeedback").get("permission"));
assertEquals("/fantasyfeedback [on|off]", metadata.getCommands().get("fantasyfeedback").get("usage"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("experience give"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("shop <place [name...]|remove <shop-uuid>|list|restock>"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("training-dummy <place [name...]|remove <dummy-uuid>|list>"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("adventure-site"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("quest-giver"));
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
@@ -48,6 +48,13 @@ class DistributionTest {
assertTrue(defaults.contains("character-swap-cooldown-seconds: 900"),
"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-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")) {
assertTrue(defaults.contains(setting), "Missing packaged adventure default: " + setting);
}
assertTrue(defaults.contains("great-sword-base-damage: 7"));
assertTrue(defaults.contains("great-sword-recovery-seconds: 0.75"));
for (String setting : java.util.List.of("frost-shard-base-damage: 4",
@@ -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));
}
}
@@ -0,0 +1,185 @@
package games.dmg.fantasystick.adventure;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdventureQuestDirectoryTest {
@TempDir Path data;
@Test void catalogRestartRolloverAcceptanceRosterClaimsAndRemovalAreDurable() throws Exception {
var clock = new MutableClock(Instant.parse("2026-09-21T12:00:00Z"));
var rules = new AdventureQuestSettings.Rules(3, 16, 30, 8, BigInteger.valueOf(100), BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
UUID world = UUID.randomUUID();
AdventureQuestState.Catalog first;
UUID solo = UUID.randomUUID(), party = UUID.randomUUID(), member = UUID.randomUUID();
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
() -> rules, new Random(7))) {
for (int index = 0; index < 6; index++) directory.placeSite(AdventureQuestState.SiteType.CAMP,
"Camp " + index, world, index * 20, 64, 0).get(5, TimeUnit.SECONDS);
directory.placeGiver("Guide", world, 0, 64, 0, 90, 0).get(5, TimeUnit.SECONDS);
first = directory.ensureToday().get(5, TimeUnit.SECONDS);
assertEquals(LocalDate.of(2026, 9, 21), first.day()); assertEquals(3, first.sites().size());
assertEquals(first, directory.ensureToday().get(5, TimeUnit.SECONDS), "Current UTC day never rerolls");
UUID site = first.sites().getFirst();
var accepted = directory.accept(first.revision(), site, AdventureQuestState.UnitType.SOLO, solo,
Set.of(solo)).get(5, TimeUnit.SECONDS);
assertEquals(AdventureQuestDirectory.Acceptance.APPLIED, accepted.status());
assertEquals(BigInteger.valueOf(100), accepted.quest().orElseThrow().rewardXp());
assertEquals(AdventureQuestDirectory.Acceptance.CONFLICT, directory.accept(first.revision(),
first.sites().get(1), AdventureQuestState.UnitType.SOLO, solo, Set.of(solo)).get().status());
assertEquals(AdventureQuestDirectory.Acceptance.CONFLICT, directory.accept(first.revision(),
first.sites().get(1), AdventureQuestState.UnitType.PARTY, party, Set.of(solo, member)).get().status());
assertTrue(directory.abandon(accepted.quest().orElseThrow().id(), solo).get(5, TimeUnit.SECONDS));
var partyQuest = directory.accept(first.revision(), site, AdventureQuestState.UnitType.PARTY, party,
Set.of(solo, member)).get(5, TimeUnit.SECONDS).quest().orElseThrow();
var roster = directory.roster(site).get(5, TimeUnit.SECONDS); assertEquals(8, roster.size());
assertTrue(directory.complete(partyQuest.id(), Set.of(solo, member)).get(5, TimeUnit.SECONDS));
assertEquals(AdventureQuestDirectory.Acceptance.CLAIMED, directory.accept(first.revision(), site,
AdventureQuestState.UnitType.SOLO, solo, Set.of(solo)).get().status());
}
try (var restarted = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
() -> rules, new Random(999))) {
assertEquals(first, restarted.ensureToday().get(5, TimeUnit.SECONDS));
assertEquals(8, restarted.roster(first.sites().getFirst()).get(5, TimeUnit.SECONDS).size());
clock.advance(Duration.ofDays(1));
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();
assertTrue(restarted.removeSite(removed).get(5, TimeUnit.SECONDS).isPresent());
assertTrue(restarted.findQuest(quest.id()).isEmpty(), "Removing an offered site expires its quests");
assertTrue(restarted.ensureToday().get().sites().contains(removed), "Catalog slot stays unavailable until reset");
}
}
@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))){
var site=directory.placeSite(AdventureQuestState.SiteType.CHAMPION,"Summit",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS);
var catalog=directory.ensureToday().get(5,TimeUnit.SECONDS);var character=UUID.randomUUID();
var quest=directory.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.SOLO,character,Set.of(character)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
assertEquals(BigInteger.valueOf(150),quest.rewardXp());assertEquals(BigInteger.valueOf(40),quest.rewardCoins());
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.roster(site.id()).get(5,TimeUnit.SECONDS));
}
}
@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 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);
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());
}
}
@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());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"));
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,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());}
}
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;}
@Override public Instant instant(){return value;}
}
}
@@ -0,0 +1,30 @@
package games.dmg.fantasystick.adventure;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayDeque;
import org.bukkit.entity.EntityType;
import org.junit.jupiter.api.Test;
class CampRosterTest {
@Test void exactWeightedEndpointsGuaranteesAndCapsAreDeterministicBeforeShuffle() {
var rolls = new ArrayDeque<>(java.util.List.of(39, 40, 74, 75, 94, 95));
var roster = CampRoster.roll(12, () -> rolls.removeFirst(), bound -> 0);
assertEquals(12, roster.size()); assertEquals(7, count(roster, EntityType.HUSK));
assertEquals(2, count(roster, EntityType.PILLAGER)); assertEquals(2, count(roster, EntityType.VINDICATOR));
assertEquals(1, count(roster, EntityType.WITCH));
var witches = CampRoster.roll(8, () -> 95, bound -> bound - 1);
assertEquals(7, count(witches, EntityType.HUSK)); assertEquals(1, count(witches, EntityType.WITCH));
var vindicators = CampRoster.roll(8, () -> 75, bound -> 0);
assertEquals(6, count(vindicators, EntityType.HUSK)); assertEquals(2, count(vindicators, EntityType.VINDICATOR));
var pillagers = CampRoster.roll(8, () -> 40, bound -> 0);
assertEquals(4, count(pillagers, EntityType.HUSK)); assertEquals(4, count(pillagers, EntityType.PILLAGER));
assertThrows(IllegalArgumentException.class, () -> CampRoster.roll(3, () -> 0, bound -> 0));
assertThrows(IllegalArgumentException.class, () -> CampRoster.roll(8, () -> 100, bound -> 0));
}
private static long count(java.util.List<EntityType> roster, EntityType type) {
return roster.stream().filter(type::equals).count();
}
}
@@ -7,13 +7,14 @@ import org.junit.jupiter.api.Test;
class BasicAttackTest {
@Test void followsTheApprovedStrengthFormulaWithoutAStatCap() {
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.ZERO));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.TEN));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(11)));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(14)));
for (int strength : new int[]{0, 9, 10, 11})
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(strength)), "STR " + strength);
for (int strength : new int[]{12, 13})
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(strength)), "STR " + strength);
for (int strength : new int[]{14, 15})
assertEquals(BigInteger.valueOf(3), BasicAttack.damage(BigInteger.valueOf(strength)), "STR " + strength);
var huge = BigInteger.TEN.pow(1000);
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO), BasicAttack.damage(huge));
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.ONE), BasicAttack.damage(huge));
assertThrows(IllegalArgumentException.class, () -> BasicAttack.damage(BigInteger.valueOf(-1)));
assertThrows(NullPointerException.class, () -> BasicAttack.damage(null));
}
@@ -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,66 @@
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)){
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 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());}
}
}
@@ -0,0 +1,55 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdventureQuestSettingsTest {
@TempDir Path data;
@Test void packagedValuesBoundsAndSaveBeforePublicationAreDurable() throws Exception {
var initial = new LinkedHashMap<String, String>();
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(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());
assertEquals(java.math.BigInteger.valueOf(150), rules.championRewardXp());
assertEquals(java.math.BigInteger.valueOf(40), rules.championRewardCoins());
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new AdventureQuestSettings(data, initial)) {
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"));
}
assertInvalid(initial, AdventureQuestSettings.DAILY_COUNT, "21");
assertInvalid(initial, AdventureQuestSettings.RADIUS, "3");
assertInvalid(initial, AdventureQuestSettings.RESET_SECONDS, "601");
assertInvalid(initial, AdventureQuestSettings.CAMP_ENEMIES, "3");
assertInvalid(initial, AdventureQuestSettings.CAMP_REWARD_XP, "-1");
assertInvalid(initial, AdventureQuestSettings.CAMP_REWARD_COINS, "2147483648");
assertInvalid(initial, AdventureQuestSettings.CHAMPION_HEALTH, "0");
assertInvalid(initial, AdventureQuestSettings.CHAMPION_HEALTH, "2049");
assertInvalid(initial, AdventureQuestSettings.CHAMPION_DAMAGE, "0");
assertInvalid(initial, AdventureQuestSettings.CHAMPION_DAMAGE, "2049");
assertInvalid(initial, AdventureQuestSettings.CHAMPION_REWARD_XP, "-1");
assertInvalid(initial, AdventureQuestSettings.CHAMPION_REWARD_COINS, "2147483648");
var blocked=data.resolve("blocked");Files.writeString(blocked,"not a directory");
try(var settings=new AdventureQuestSettings(blocked,initial)){
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(AdventureQuestSettings.CHAMPION_HEALTH,"120").toCompletableFuture().get(5,TimeUnit.SECONDS));
assertEquals("100",settings.value(AdventureQuestSettings.CHAMPION_HEALTH));
}
}
private static void assertInvalid(java.util.Map<String, String> initial, String key, String value) {
var changed = new LinkedHashMap<>(initial); changed.put(key, value);
assertThrows(IllegalArgumentException.class, () -> AdventureQuestSettings.parse(changed));
}
}
@@ -0,0 +1,51 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class HardMobXpSettingsTest {
@TempDir Path data;
@Test void validatesWholePoolAndPersistsBeforePublicationWithoutLosingOtherKeys() throws Exception {
assertEquals(BigInteger.ZERO, HardMobXpSettings.parse("0"));
assertEquals(BigInteger.valueOf(20), HardMobXpSettings.parse("20"));
assertEquals(BigInteger.valueOf(Integer.MAX_VALUE), HardMobXpSettings.parse("2147483647"));
for (String invalid : java.util.List.of("-1", "2147483648", "1.5", "x"))
assertThrows(IllegalArgumentException.class, () -> HardMobXpSettings.parse(invalid));
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new HardMobXpSettings(data, "20")) {
var update = settings.set("25").toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals("20", settings.value());
assertTrue(settings.publish(update)); assertEquals("25", settings.value());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated"));
assertEquals("25", String.valueOf(saved.get(HardMobXpSettings.KEY)));
}
}
@Test void failedWriteCloseAndSupersededPublicationRetainAuthoritativeValue() throws Exception {
try (var settings = new HardMobXpSettings(data, "20", (from, to) -> {
throw new java.io.IOException("simulated");
})) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set("25").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("20", settings.value());
}
try (var settings = new HardMobXpSettings(data.resolve("ordered"), "20")) {
var first = settings.set("21").toCompletableFuture().get(5, TimeUnit.SECONDS);
var second = settings.set("22").toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(settings.publish(second)); assertFalse(settings.publish(first)); assertEquals("22", settings.value());
}
var closed = new HardMobXpSettings(data.resolve("closed"), "20"); closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,
() -> closed.set("25").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
}
@@ -11,9 +11,11 @@ import org.junit.jupiter.api.Test;
class CombatRewardRulesTest {
@Test void poolsAndRoundedSharesPreserveApprovedArithmetic() {
var rules = new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), new BigDecimal("3"));
var rules = new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(20),
BigInteger.valueOf(100), new BigDecimal("3"));
assertEquals(BigInteger.ONE, rules.pool(CombatRewardRules.Target.PASSIVE, 99));
assertEquals(BigInteger.TEN, rules.pool(CombatRewardRules.Target.ORDINARY, 99));
assertEquals(BigInteger.valueOf(20), rules.pool(CombatRewardRules.Target.HARD, 99));
assertEquals(BigInteger.valueOf(100), rules.pool(CombatRewardRules.Target.BOSS, 99));
assertEquals(BigInteger.valueOf(70), rules.pool(CombatRewardRules.Target.FANTASY_CHARACTER, 7));
assertEquals(BigInteger.valueOf(4), rules.share(BigInteger.TEN, 3));
@@ -41,7 +43,10 @@ class CombatRewardRulesTest {
@Test void rejectsInvalidRewardConfigurationAndArithmeticInputs() {
assertAll(
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.valueOf(-1), BigInteger.TEN, BigInteger.TEN, BigDecimal.ONE)),
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.valueOf(-1), BigInteger.TEN,
BigInteger.valueOf(20), BigInteger.TEN, BigDecimal.ONE)),
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.ONE, BigInteger.TEN,
BigInteger.valueOf(-1), BigInteger.TEN, BigDecimal.ONE)),
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.TEN, new BigDecimal("-0.1"))),
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().share(BigInteger.ONE, 0)),
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().pool(CombatRewardRules.Target.FANTASY_CHARACTER, 0)),