Compare commits

...
31 Commits
Author SHA1 Message Date
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
dmg 710a29a43c feat(shield-bash): shorten strike cooldown
Release / release (push) Successful in 10m4s
2026-09-21 16:52:02 -04:00
dmg d3f58f4223 feat(great-sword): shorten attack recovery
Release / release (push) Successful in 10m13s
2026-09-21 16:43:22 -04:00
dmg 87024bfd24 feat(recovery): shorten initial healing delay
Release / release (push) Successful in 9m51s
2026-09-21 16:31:18 -04:00
dmg d8bbe78a08 feat(frost-shard): use fast arrow flight
Release / release (push) Successful in 9m56s
2026-09-21 16:23:46 -04:00
dmg 37be6b13fa feat(fireball): use fast arrow flight
Release / release (push) Successful in 9m54s
2026-09-21 16:13:18 -04:00
dmg 1dcc7cfae8 feat(training): add durable damage dummies
Release / release (push) Successful in 10m2s
2026-09-21 15:48:52 -04:00
dmg e16570cb35 feat(feedback): report accepted combat effects
Release / release (push) Successful in 10m7s
2026-09-21 14:48:24 -04:00
dmg be8870502f fix(session): prioritize native door interaction
Release / release (push) Successful in 9m57s
2026-09-21 11:57:22 -04:00
dmg 8b5461753c fix(book): shorten gear summary labels
Release / release (push) Successful in 9m45s
2026-09-21 11:49:45 -04:00
dmg 91412fa1ae fix(party): show Registrar fees in menus
Release / release (push) Successful in 9m46s
2026-09-21 11:01:37 -04:00
dmg 61bc67da3b fix(shop): simplify armor market labels
Release / release (push) Successful in 10m48s
2026-09-21 10:54:54 -04:00
dmg 1cc1012ecd fix(shop): allow Armorer use with bound controls
Release / release (push) Successful in 10m7s
2026-09-21 10:24:44 -04:00
dmg 396baf473f fix(party): allow Registrar use with bound controls
Release / release (push) Successful in 10m14s
2026-09-21 10:17:56 -04:00
dmg 1468db0533 feat(shop): add shared daily armor market
Release / release (push) Successful in 10m16s
2026-09-21 09:48:45 -04:00
dmg f9ab5b6e46 feat(gear): add permanent character armor
Release / release (push) Successful in 10m2s
2026-09-21 08:24:22 -04:00
dmg 0042f539a5 feat(party): persist character-owned parties
Release / release (push) Successful in 9m39s
2026-09-21 07:46:58 -04:00
dmg 9d6b945f82 fix(economy): persist PvP coin escrow
Release / release (push) Successful in 9m14s
2026-09-21 00:58:14 -04:00
dmg 5c5f774dea feat(economy): add character coins and exact rewards
Release / release (push) Successful in 5m11s
2026-09-21 00:56:09 -04:00
dmg 0536d4c717 feat(presentation): hide cooling controls
Release / release (push) Successful in 8m43s
2026-09-21 00:26:10 -04:00
dmg 079555900c feat(passives): add low-light vision
Release / release (push) Successful in 8m51s
2026-09-21 00:12:43 -04:00
186 changed files with 8862 additions and 971 deletions

No files matched your search

@@ -36,9 +36,26 @@ public final class FantasyStickPlugin extends JavaPlugin {
private EquipmentSessionEvents sessionEvents;
private games.dmg.fantasystick.book.CharacterBookController books;
private games.dmg.fantasystick.party.PartyRoster parties;
private games.dmg.fantasystick.party.CharacterPartyDirectory characterParties;
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
private games.dmg.fantasystick.party.PartyManagement partyManagement;
private games.dmg.fantasystick.party.PartyRegistrarMenus partyRegistrarMenus;
private games.dmg.fantasystick.party.PartyEvents partyEvents;
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
private games.dmg.fantasystick.shop.ArmorShop armorShop;
private games.dmg.fantasystick.shop.ArmorShopMenus armorShopMenus;
private org.bukkit.scheduler.BukkitTask armorMarketPulse;
private org.bukkit.scheduler.BukkitTask armorMenuPulse;
private games.dmg.fantasystick.party.PartyCombat partyCombat;
private EquipmentAdminCommand admin;
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
private games.dmg.fantasystick.presentation.CombatFeedback combatFeedback;
private volatile Object lifetime;
private CharacterPresentation presentation;
private ProtocolPresentation protocolPresentation;
@@ -52,6 +69,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.ability.AbilityRegistry abilityRegistry;
private games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
private games.dmg.fantasystick.ability.FrostShard frostShard;
private games.dmg.fantasystick.persistence.FrostShardSettings frostShardSettings;
private games.dmg.fantasystick.ability.Longbow longbow;
@@ -98,6 +116,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.persistence.PartySettings partySettings;
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.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
@@ -110,6 +136,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
private games.dmg.fantasystick.passive.SpeedI speedI;
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@@ -140,6 +167,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
var partyEconomyConfiguration = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.PartyEconomySettings.keys()) partyEconomyConfiguration.put(key, getConfig().getString(key,games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(key)));
var gearConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.GearSettings.keys())gearConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.GearSettings.defaultValue(key)));
var coinConfiguration = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.CoinSettings.keys()) {
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(
@@ -156,8 +197,13 @@ 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 fireballSettings = fireballSettings();
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();
var frostShardConfiguration = frostShardConfiguration();
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
@@ -195,14 +241,36 @@ public final class FantasyStickPlugin extends JavaPlugin {
var meteorInitial = games.dmg.fantasystick.persistence.MeteorSettings.parse(meteorConfiguration);
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
partyEconomySettings = new games.dmg.fantasystick.persistence.PartyEconomySettings(
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());
var armorRandom=java.util.random.RandomGenerator.getDefault();
armorMarket=new games.dmg.fantasystick.shop.ArmorMarket(
new games.dmg.fantasystick.persistence.YamlArmorCatalogRepository(getDataFolder().toPath()),
new games.dmg.fantasystick.persistence.ArmorPurchaseOperationStore(getDataFolder().toPath()),
characters,gearSettings::rules,Clock.systemUTC(),
(minimum,maximum)->(int)armorRandom.nextLong(minimum,(long)maximum+1));
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
task -> getServer().getScheduler().runTask(this, task),
player -> new PlayerSheetDrops(player, event -> getServer().getPluginManager().callEvent(event)),
problem -> getLogger().log(Level.WARNING, "Character craft " + problem.operation() + " for player "
+ problem.player() + "; character records " + problem.characters(), problem.cause()));
java.util.function.Consumer<Runnable> nextTick = task -> getServer().getScheduler().runTask(this, task);
try {
feedbackPreferences = new games.dmg.fantasystick.persistence.PlayerFeedbackPreferences(getDataFolder().toPath());
} catch (java.io.IOException invalidPreferences) {
throw new java.io.UncheckedIOException("Player feedback preferences could not be loaded", invalidPreferences);
}
feedbackCommand = new games.dmg.fantasystick.session.FantasyFeedbackCommand(feedbackPreferences, nextTick,
failure -> getLogger().log(Level.WARNING, "Player feedback preference failed", failure));
java.util.function.Consumer<Throwable> presentationFailures = problem ->
getLogger().log(Level.WARNING, "Character presentation failed", problem);
// This supplier belongs to one enable, never the mutable plugin field after re-enable.
@@ -234,23 +302,60 @@ public final class FantasyStickPlugin extends JavaPlugin {
switchDelaySettings = new games.dmg.fantasystick.persistence.SwitchDelaySettings(getDataFolder().toPath(), switchDelay);
roleplaySettings = new games.dmg.fantasystick.persistence.RoleplaySettings(getDataFolder().toPath(), roleplayDisabled);
partySettings = new games.dmg.fantasystick.persistence.PartySettings(getDataFolder().toPath(), partyMaximum);
try {
characterParties = new games.dmg.fantasystick.party.CharacterPartyDirectory(
new games.dmg.fantasystick.persistence.YamlPartyRepository(getDataFolder().toPath()), partySettings::maximum);
partyRegistrars = new games.dmg.fantasystick.party.PartyRegistrarDirectory(
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());
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
} catch (java.io.IOException invalidManagedState) {
throw new java.io.UncheckedIOException("Persistent party, shop, training or adventure state could not be loaded", invalidManagedState);
}
characterParties.suspend();
sheetResourcePack = new games.dmg.fantasystick.presentation.SheetResourcePack(sheetResourcePackSettings,
(player, failure) -> getLogger().log(Level.WARNING, "Sheet resource-pack delivery failed for " + player, failure));
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
sessions.attachSwitchDelay(switchDelaySettings::delay);
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
var player = getServer().getPlayer(id);
return player != null && player.isOnline();
}, partySettings::maximum);
combatFeedback = new games.dmg.fantasystick.presentation.CombatFeedback(feedbackPreferences, sessions);
trainingDummyController = new games.dmg.fantasystick.training.TrainingDummyController(this, trainingDummies);
parties = new games.dmg.fantasystick.party.PartyRoster(characterParties, sessions, partySettings::maximum);
characterParties.attachEnded(former -> nextTick.accept(() -> {
String message = "Party ended: " + former.displayName();
for (java.util.UUID member : former.members()) {
var activeMember = sessions.activeCharacter(member).filter(sessions::current).orElse(null);
org.bukkit.entity.Player online = activeMember == null ? null : getServer().getPlayer(activeMember.player());
if (online != null && online.isOnline()) online.showTitle(net.kyori.adventure.title.Title.title(
net.kyori.adventure.text.Component.text(message), net.kyori.adventure.text.Component.empty()));
}
}));
sessions.attachPartyDefeat(characterParties::defeat);
var reconcilingParties = characterParties;
var partyCharacters = reconcilingParties.all().stream().flatMap(party -> java.util.stream.Stream.concat(
party.members().stream(), party.invitations().stream().map(games.dmg.fantasystick.party.CharacterParty.Invitation::character))).distinct().toList();
java.util.concurrent.CompletableFuture.allOf(partyCharacters.stream().map(character -> characters.find(character)
.thenCompose(record -> record.isEmpty() || record.orElseThrow().dead()
? reconcilingParties.defeat(character) : java.util.concurrent.CompletableFuture.completedFuture(
games.dmg.fantasystick.party.CharacterPartyDirectory.Defeat.UNCHANGED))).toArray(java.util.concurrent.CompletableFuture[]::new))
.whenComplete((ignored, failure) -> { if (failure == null) reconcilingParties.resume();
else getLogger().log(Level.SEVERE, "Dead-character party reconciliation failed; party authority remains suspended", failure); });
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
sessions, Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
sessions, parties, coinSettings::rules, java.util.random.RandomGenerator.getDefault(),
Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
sessions.attachPvpDefeat(combatExperience::consumePvpDefeat);
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
fireballSettings = new games.dmg.fantasystick.persistence.FireballSettings(
getDataFolder().toPath(), fireballConfiguration);
fireball = new games.dmg.fantasystick.ability.Fireball(fireballInitial);
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
getDataFolder().toPath(), frostShardConfiguration);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
@@ -344,6 +449,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
var gearItems=new games.dmg.fantasystick.items.GearItems(this);sessions.attachGear(gearItems);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
@@ -353,7 +459,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING,
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()));
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()), combatFeedback);
quickerRegenerationSettings = new games.dmg.fantasystick.persistence.QuickerRegenerationSettings(
getDataFolder().toPath(), quickerRegenerationInitial.bonus().toString());
quickerRegeneration = new games.dmg.fantasystick.passive.QuickerRegenerationI(quickerRegenerationInitial);
@@ -364,13 +470,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
getDataFolder().toPath(), healthStealInitial.healing().toString());
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()));
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()),
combatFeedback);
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
lowLightVision = new games.dmg.fantasystick.passive.LowLightVision(sessions);
passiveRegistry = new games.dmg.fantasystick.passive.PassiveRegistry();
passiveRegistry.register(quickerRegeneration.definition());
passiveRegistry.register(quickerRegenerationII.definition());
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()));
@@ -392,7 +502,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 -> { },
Clock.systemUTC(), nextTick, abilityScheduler, report -> { }, combatFeedback, trainingDummyController,
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
+ "; ability " + problem.ability(), problem.cause()));
abilityEngine.install(this);
@@ -403,7 +513,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var player = getServer().getPlayer(id);
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
}, abilityRegistry, abilityEngine, passiveRegistry, passiveEngine);
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(
sessions, Clock.systemUTC(), combatFeedback, trainingDummyController);
basicAttack = localBasicAttack;
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
books.accept(input);
@@ -411,6 +522,30 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.accept(input);
});
sessionEvents.attachAbilityRegistry(abilityRegistry);
sessionEvents.attachGear(gearItems);
partyManagement = new games.dmg.fantasystick.party.PartyManagement(characterParties, partyRegistrars,
characters, sessions, partyEconomySettings,
new games.dmg.fantasystick.persistence.PartyOperationStore(getDataFolder().toPath()), nextTick,
failure -> getLogger().log(Level.WARNING, "Party Registrar mutation failed", failure));
partyRegistrarMenus = new games.dmg.fantasystick.party.PartyRegistrarMenus(this, partyManagement);
partyRegistrarController = new games.dmg.fantasystick.party.PartyRegistrarController(this, partyRegistrars,
partyRegistrarMenus::open);
armorShop=new games.dmg.fantasystick.shop.ArmorShop(armorers,sessions,armorMarket,nextTick,
failure->getLogger().log(Level.WARNING,"Fantasy Armorer purchase failed",failure));
armorShopMenus=new games.dmg.fantasystick.shop.ArmorShopMenus(this,armorShop);
var localArmorMenus=armorShopMenus;
armorMarket.attachListener(catalog->{if(lifetime!=generation)return;try{nextTick.accept(()->{
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);
combatExperience.attachExcluded(adventureQuestController::managed);
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.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,
@@ -418,6 +553,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
inspireSettings, inspire, abilityRegistry);
admin.attachFireball(fireballSettings, fireball);
admin.attachMendingLight(mendingLightSettings, mendingLight);
admin.attachPurify(purifySettings, purify);
admin.attachGuardianBond(guardianBondSettings, guardianBond);
@@ -435,6 +571,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
admin.attachPartyEconomySettings(partyEconomySettings);
admin.attachPartyRegistrars(partyRegistrars, partyRegistrarController);
admin.attachTrainingDummies(trainingDummies, trainingDummyController);
admin.attachGearSettings(gearSettings);
admin.attachArmorers(armorers,armorerController);
admin.attachArmorMarket(armorMarket);
admin.attachCoinSettings(coinSettings);
admin.attachHardMobXpSettings(hardMobXpSettings, combatExperience);
admin.attachAdventureQuests(adventureQuestCommands, adventureQuestSettings);
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
@@ -447,6 +592,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
var playerFeedbackCommand = java.util.Objects.requireNonNull(getCommand("fantasyfeedback"),
"Missing packaged fantasyfeedback command");
playerFeedbackCommand.setExecutor(feedbackCommand);
playerFeedbackCommand.setTabCompleter(feedbackCommand);
getServer().getPluginManager().registerEvents(crafting, this);
getServer().getPluginManager().registerEvents(guard, this);
getServer().getPluginManager().registerEvents(anvilNames, this);
@@ -454,6 +603,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(chatNames, this);
getServer().getPluginManager().registerEvents(partyEvents, this);
getServer().getPluginManager().registerEvents(partyCombat, this);
getServer().getPluginManager().registerEvents(partyRegistrarController, this);
getServer().getPluginManager().registerEvents(trainingDummyController, this);
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
getServer().getPluginManager().registerEvents(armorerController,this);
getServer().getPluginManager().registerEvents(armorShopMenus,this);
getServer().getPluginManager().registerEvents(adventureQuestController,this);
partyRegistrarController.install();
trainingDummyController.install();
armorerController.install();
adventureQuestController.install();
var localArmorMarket=armorMarket;
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
localArmorMarket.current().whenComplete((catalog,failure)->{
if(failure!=null&&lifetime==generation)getLogger().log(Level.WARNING,"Scheduled Fantasy Armorer restock check failed",failure);});},20L,20L);
armorMenuPulse=getServer().getScheduler().runTaskTimer(this,localArmorMenus::refreshOpen,1200L,1200L);
partyCombat.install(this);
getServer().getPluginManager().registerEvents(healthSteal, this);
getServer().getPluginManager().registerEvents(combatExperience, this);
@@ -482,6 +646,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(outOfCombatRecovery, this);
outOfCombatRecovery.install(this);
speedI.install(this);
lowLightVision.install(this);
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
@@ -510,11 +675,28 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingProtocol = protocolPresentation; protocolPresentation = null;
var closingChat = chatNames; chatNames = null;
var closingAdmin = admin; admin = null;
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
var closingParties = parties; parties = null;
var closingCharacterParties = characterParties; characterParties = null;
var closingPartyRegistrars = partyRegistrars; partyRegistrars = null;
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 closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
var closingPartyManagement = partyManagement; partyManagement = null;
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
var closingArmorShopMenus=armorShopMenus;armorShopMenus=null;armorShop=null;
var closingArmorMarket=armorMarket;armorMarket=null;var closingArmorMarketPulse=armorMarketPulse;armorMarketPulse=null;
var closingArmorMenuPulse=armorMenuPulse;armorMenuPulse=null;
var closingCrafting = crafting; crafting = null;
var closingGuard = guard; guard = null;
var closingAnvil = anvilNames; anvilNames = null;
@@ -530,6 +712,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingCombatExperience = combatExperience; combatExperience = null;
var closingAbilityEngine = abilityEngine; abilityEngine = null;
var closingFireball = fireball; fireball = null;
var closingFireballSettings = fireballSettings; fireballSettings = null;
var closingFrostShard = frostShard; frostShard = null;
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
var closingLongbow = longbow; longbow = null;
@@ -566,6 +749,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
var closingPartySettings = partySettings; partySettings = null;
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;
@@ -587,13 +775,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingHealthStealSettings = healthStealSettings; healthStealSettings = null;
var closingHealthSteal = healthSteal; healthSteal = null;
var closingSpeedI = speedI; speedI = null;
var closingLowLightVision = lowLightVision; lowLightVision = null;
var closingPassiveEngine = passiveEngine; passiveEngine = null;
quickerRegeneration = null; quickerRegenerationII = null; passiveRegistry = null;
// Retire client resources independently of successful ordinary-equipment restoration.
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
@@ -623,6 +815,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
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 (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);
@@ -630,8 +829,31 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingQuickerRegenerationIISettings != null) cleanup("Quicker Regeneration II configuration", closingQuickerRegenerationIISettings::close);
if (closingHealthStealSettings != null) cleanup("Health Steal configuration", closingHealthStealSettings::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingFeedbackPreferences == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFeedbackPreferences.drained().toCompletableFuture(),
closingCharacterParties == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingCharacterParties.drained().toCompletableFuture(),
closingPartyRegistrars == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartyRegistrars.drained().toCompletableFuture(),
closingTrainingDummies == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingTrainingDummies.drained().toCompletableFuture(),
closingPartyManagement == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartyManagement.drained().toCompletableFuture(),
closingArmorers==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorers.drained().toCompletableFuture(),
closingArmorMarket==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorMarket.drained().toCompletableFuture(),
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(),
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFireballSettings.drained().toCompletableFuture(),
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostSettings.drained().toCompletableFuture(),
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -707,8 +929,22 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingPassiveEngine != null) cleanup("passive engine", closingPassiveEngine::close);
if (closingHealthSteal != null) cleanup("Health Steal", closingHealthSteal::close);
if (closingSpeedI != null) cleanup("Speed I", closingSpeedI::close);
if (closingLowLightVision != null) cleanup("Low-Light Vision", closingLowLightVision::close);
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
if (closingParties != null) cleanup("parties", closingParties::close);
if (closingCharacterParties != null) cleanup("character parties", closingCharacterParties::close);
if (closingPartyRegistrarMenus != null) cleanup("party registrar menus", closingPartyRegistrarMenus::close);
if (closingPartyManagement != null) cleanup("party management", closingPartyManagement::close);
if (closingPartyRegistrarController != null) cleanup("party registrar entities", closingPartyRegistrarController::close);
if (closingPartyRegistrars != null) cleanup("party registrars", closingPartyRegistrars::close);
if (closingTrainingDummyController != null) cleanup("training dummy entities", closingTrainingDummyController::close);
if (closingTrainingDummies != null) cleanup("training dummies", closingTrainingDummies::close);
if(closingArmorMarketPulse!=null)cleanup("armor market schedule",closingArmorMarketPulse::cancel);
if(closingArmorMenuPulse!=null)cleanup("armor menu schedule",closingArmorMenuPulse::cancel);
if(closingArmorShopMenus!=null)cleanup("armor shop menus",closingArmorShopMenus::close);
if(closingArmorerController!=null)cleanup("armorer entities",closingArmorerController::close);
if(closingArmorMarket!=null)cleanup("armor market",closingArmorMarket::close);
if(closingArmorers!=null)cleanup("armorers",closingArmorers::close);
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
if (closingEvents != null) cleanup("session events", closingEvents::close);
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
@@ -718,6 +954,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingChat);
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingPartyRegistrarController);
unregister(closingTrainingDummyController);
unregister(closingPartyRegistrarMenus);
unregister(closingArmorerController);unregister(closingArmorShopMenus);
unregister(closingAdventureQuestController);
unregister(closingCombatExperience);
unregister(closingHealthSteal);
unregister(closingFireball);
@@ -777,30 +1018,26 @@ 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);
}
}
private games.dmg.fantasystick.ability.Fireball.Settings fireballSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("fireball-base-damage", "6"));
var cooldown = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-cooldown-seconds", "4")).longValueExact());
double range = new java.math.BigDecimal(getConfig().getString("fireball-range-blocks", "32")).doubleValue();
var lifetime = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-lifetime-seconds", "5")).longValueExact());
return new games.dmg.fantasystick.ability.Fireball.Settings(base, cooldown, range, lifetime);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Fireball settings must use supported nonnegative numeric values", invalid);
private java.util.Map<String, String> fireballConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.FireballSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.FireballSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.FireballSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> outOfCombatRecoveryConfiguration() {
@@ -983,7 +1220,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "1.2"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
} catch (ArithmeticException | NumberFormatException 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);
@@ -39,6 +39,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final Consumer<Runnable> nextTick;
private final AbilityScheduler scheduler;
private final Consumer<AbilityReport> reports;
private final AbilityFeedback feedback;
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Consumer<Failure> failures;
private final Map<Key, Object> pending = new HashMap<>();
private final Map<Key, Long> generations = new HashMap<>();
@@ -49,10 +51,27 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
this(registry, characters, sessions, parties, clock, nextTick, scheduler, reports,
(definition, input, type, target, amount) -> { },
games.dmg.fantasystick.training.TrainingDamage.passthrough(), failures);
}
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, AbilityFeedback feedback, Consumer<Failure> failures) {
this(registry, characters, sessions, parties, clock, nextTick, scheduler, reports, feedback,
games.dmg.fantasystick.training.TrainingDamage.passthrough(), failures);
}
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, AbilityFeedback feedback,
games.dmg.fantasystick.training.TrainingDamage trainingDamage, Consumer<Failure> failures) {
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.scheduler = Objects.requireNonNull(scheduler); this.reports = Objects.requireNonNull(reports);
this.feedback = Objects.requireNonNull(feedback); this.trainingDamage = Objects.requireNonNull(trainingDamage);
this.failures = Objects.requireNonNull(failures);
}
@@ -326,11 +345,21 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
}
owned.add(resource);
}
@Override public boolean trainingDummy(Entity target) { return trainingDamage.target(Objects.requireNonNull(target)); }
@Override public void damage(org.bukkit.entity.LivingEntity target, double amount,
org.bukkit.damage.DamageSource source) {
Objects.requireNonNull(target); Objects.requireNonNull(source);
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(amount, source));
measured.ifPresent(value -> report(AbilityReport.Type.DAMAGE, target, value));
}
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
if (!stillCurrent() || definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT
|| definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION
|| target == null || !AbilityEngine.this.eligible(definition.targetPolicy(), input, target)) return;
reports.accept(new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant()));
var report = new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant());
reports.accept(report);
try { feedback.report(definition, input, type, target, report.amount()); }
catch (RuntimeException failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
}
@Override public void finish() { close(); }
@Override public void close() {
@@ -5,7 +5,9 @@ import java.math.BigDecimal;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
/** Bounded owned runtime supplied by the shared engine after durable cast authorization. */
public interface AbilityExecution extends AutoCloseable {
@@ -16,6 +18,8 @@ public interface AbilityExecution extends AutoCloseable {
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
AbilityScheduler.Task later(Duration delay, Runnable action);
void own(AutoCloseable resource);
boolean trainingDummy(Entity target);
void damage(LivingEntity target, double amount, DamageSource source);
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
void finish();
@Override void close();
@@ -0,0 +1,11 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import org.bukkit.entity.Entity;
/** Presentation sink for an authoritative applied ability outcome. */
@FunctionalInterface
public interface AbilityFeedback {
void report(AbilityDefinition definition, ControlInput input, AbilityReport.Type type, Entity target, BigDecimal amount);
}
@@ -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(),
@@ -117,7 +117,7 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
.withDirectEntity(caster).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
try { target.damage(amount, source); }
try { execution.damage(target, amount, source); }
finally { if (damaging == attempt) damaging = null; }
}
@@ -15,47 +15,50 @@ import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.util.Vector;
/** Fireball's owned snowball projectile, direct custom damage and bounded cleanup adapter. */
/** Fireball's owned arrow flight, direct custom damage and bounded cleanup adapter. */
public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
private static final double SPEED_PER_TICK = 1.0;
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime) {
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime, double speedPerTick) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0) {
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 64) {
throw new IllegalArgumentException("Invalid Fireball settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(4), 32, Duration.ofSeconds(5));
return new Settings(BigInteger.valueOf(6), Duration.ofMillis(1200), 32, Duration.ofSeconds(5), 3);
}
}
private final Settings settings;
private volatile Settings settings;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
private final class State implements AutoCloseable {
final Snowball projectile;
final Arrow projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence) {
this.projectile = projectile; this.execution = execution; this.origin = origin; this.intelligence = intelligence;
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
this.intelligence = intelligence; this.settings = settings;
}
@Override public void close() {
if (finished) return;
@@ -66,16 +69,19 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
List.of("Launch a bounded fiery snowball.", "Deals " + settings.baseDamage() + " + INT modifier damage."),
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,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, settings.cooldown(),
Map.of("base-damage", new BigDecimal(settings.baseDamage()),
"range", BigDecimal.valueOf(settings.range()).stripTrailingZeros(),
"lifetime", BigDecimal.valueOf(settings.lifetime().toMillis(), 3).stripTrailingZeros()),
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(),
"lifetime", seconds(current.lifetime()),
"projectile-speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
@@ -95,21 +101,24 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = settings;
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Fireball direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(SPEED_PER_TICK));
var projectile = player.launchProjectile(Arrow.class, direction.normalize().multiply(current.speedPerTick()));
projectile.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
projectile.setDamage(0); projectile.setKnockbackStrength(0); projectile.setPierceLevel(0); projectile.setCritical(false);
projectile.setVisualFire(true);
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT));
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT), current);
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Fireball projectile identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(settings.range() / SPEED_PER_TICK));
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(settings.lifetime(), execution::finish);
execution.later(current.lifetime(), execution::finish);
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@@ -121,13 +130,13 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
BigInteger amount = damage(settings.baseDamage(), state.intelligence);
BigInteger amount = damage(state.settings.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target;
damaging = state;
try { target.damage(nativeDamage, source); }
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.execution.finish();
@@ -137,7 +146,14 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= settings.range() * settings.range();
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.HIGHEST)
public void containNativeDamage(EntityDamageByEntityEvent event) {
if (!(event.getDamager() instanceof Arrow arrow)) return;
var state = projectiles.get(arrow.getUniqueId());
if (state != null && damaging != state) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -153,6 +169,10 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
private static BigDecimal seconds(Duration duration) {
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros();
}
@Override public void close() {
if (closed) return;
closed = true;
@@ -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(),
@@ -135,7 +135,7 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
.withDirectEntity(source).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
try { target.damage(amount, sourceDamage); }
try { execution.damage(target, amount, sourceDamage); }
finally { if (damaging == attempt) damaging = null; }
affected++;
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
@@ -15,11 +15,13 @@ import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.potion.PotionEffect;
@@ -45,12 +47,12 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 32, Duration.ofSeconds(5),
1, 0, Duration.ofSeconds(2));
3, 0, Duration.ofSeconds(2));
}
}
private final class State implements AutoCloseable {
final Snowball projectile;
final Arrow projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
@@ -58,7 +60,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
LivingEntity target;
boolean accepted;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
this.intelligence = intelligence; this.settings = settings;
}
@@ -112,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(),
@@ -144,7 +146,9 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Frost Shard direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(current.speedPerTick()));
var projectile = player.launchProjectile(Arrow.class, direction.normalize().multiply(current.speedPerTick()));
projectile.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
projectile.setDamage(0); projectile.setKnockbackStrength(0); projectile.setPierceLevel(0); projectile.setCritical(false);
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT), current);
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Frost Shard projectile identity");
@@ -160,6 +164,13 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void containNativeDamage(EntityDamageByEntityEvent event) {
if (!(event.getDamager() instanceof Arrow arrow)) return;
var state = projectiles.get(arrow.getUniqueId());
if (state != null && damaging != state) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = projectiles.remove(event.getEntity().getUniqueId());
@@ -175,7 +186,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
var source = DamageSource.builder(DamageType.FREEZE).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target; damaging = state;
try { target.damage(nativeDamage, source); }
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
if (state.accepted && state.execution.stillCurrent()) {
int ticks = Math.toIntExact(Math.max(1,
@@ -27,7 +27,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
throw new IllegalArgumentException("Invalid Great Sword settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); }
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(750)); }
}
private static final class Attempt {
@@ -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);
@@ -93,7 +93,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
.withDirectEntity(player).build();
var attempt = new Attempt(execution, target);
damaging = attempt;
try { target.damage(nativeDamage, source); }
try { execution.damage(target, nativeDamage, source); }
finally { if (damaging == attempt) damaging = null; }
}
@@ -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,13 +30,13 @@ 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);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())){execution.finish();return;}
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())||execution.trainingDummy(target)){execution.finish();return;}
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
@@ -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()),
@@ -171,7 +171,7 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
state.target = target; damaging = state;
try { target.damage(nativeDamage, source); }
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.close(); state.execution.finish();
@@ -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()),
@@ -142,8 +142,7 @@ public final class MendingLight implements AbilityEffect {
target.player().setHealth(Math.min(maximum, current + restored));
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
execution.input().player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
target.player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
execution.report(AbilityReport.Type.HEALING, target.player(), amount);
} finally { execution.finish(); }
}
@@ -46,7 +46,7 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{target.damage(amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);ground.origin().getWorld().spawnParticle(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();world.spawnParticle(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);world.spawnParticle(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
@@ -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);
@@ -81,12 +81,11 @@ public final class Purify implements AbilityEffect {
if (target.player().getPotionEffect(type) == null) removed++;
}
if (removed == 0) return;
execution.input().player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
var amount = BigDecimal.valueOf(removed);
execution.report(AbilityReport.Type.CLEANSING, target.player(), amount);
if (target.player() != execution.input().player()
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
var amount = BigDecimal.valueOf(removed);
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
target.player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
}
}
@@ -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(),
@@ -199,6 +199,8 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
target.player().setHealth(Math.min(maximum, current + restored));
state.execution.report(AbilityReport.Type.HEALING, target.player(),
BigDecimal.valueOf(restored).stripTrailingZeros());
if (target.player() != state.source) supports.accept(new MendingLight.Support(
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
} finally { completePending(state); }
@@ -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);
@@ -105,8 +105,12 @@ public final class SecondWind implements AbilityEffect {
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
}
double publish = Math.min(attribute.getValue(), Math.max(player.getHealth(), persisted.health().doubleValue()));
double nativeBefore = player.getHealth();
double publish = Math.min(attribute.getValue(), Math.max(nativeBefore, persisted.health().doubleValue()));
player.setHealth(publish);
double restored = publish - nativeBefore;
if (Double.isFinite(restored) && restored > 0) execution.report(AbilityReport.Type.HEALING,
player, BigDecimal.valueOf(restored).stripTrailingZeros());
} finally { execution.finish(); }
});
} catch (RuntimeException rejected) { execution.finish(); }
@@ -35,7 +35,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|| minimumVerticalKnockback > 8) throw new IllegalArgumentException("Invalid Shield Bash settings");
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(6), 3, 0.8, 0.35);
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(3), 3, 0.8, 0.35);
}
}
private static final class Attempt {
@@ -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())),
@@ -91,7 +91,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
var attempt = new Attempt(execution, target, current); damaging = attempt;
try { target.damage(amount, source); }
try { execution.damage(target, amount, source); }
finally { if (damaging == attempt) damaging = null; execution.finish(); }
}
@@ -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);
@@ -20,9 +20,9 @@ public final class TemporaryHealth implements Listener,AutoCloseable{
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();long ownerSequence=owner.sequence;UUID source=owner.execution.input().session().character();
try{nextTick.accept(()->consume(target,before,owned,ownerSequence,source,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,long ownerSequence,UUID source,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,source,aided,BigDecimal.valueOf(consumed).stripTrailingZeros()));}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();
try{nextTick.accept(()->consume(target,before,owned,owner,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,Grant owner,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));}
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
@@ -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 replaces today's offers only when no quest or encounter is active. Run /fantasystick adventure reroll confirm to continue.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fantasystick adventure [reroll confirm]");return;}if(!authorized(sender))return;if(controller.hasActiveEncounter()){sender.sendMessage("Adventure reroll refused: an encounter is active.");return;}var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reroll failed; the current catalog is unchanged.");return;}switch(result.status()){case ACTIVE->sender.sendMessage("Adventure reroll refused: an accepted quest is active.");case STALE->sender.sendMessage("Adventure reroll refused: the catalog changed; inspect it and try again.");case APPLIED->{controller.catalogRerolled();var catalog=result.catalog().orElseThrow();sender.sendMessage("Rerolled daily adventures: revision="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().isEmpty())sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" 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().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,108 @@
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.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;
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 org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
private static final class MenuHolder implements InventoryHolder {final UUID catalog;MenuHolder(UUID catalog){this.catalog=catalog;}@Override public Inventory getInventory(){return null;}}
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final 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");}
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);}
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 catalogRerolled(){if(hasActiveEncounter())throw new IllegalStateException("Active adventure encounter");readyAt.clear();}
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.displayName()));villager.setCustomNameVisible(true);}
public void open(Player player){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{if(failure!=null||context(player)==null){player.sendMessage("Daily quests are unavailable.");return;}var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision()),Math.max(9,Math.min(54,((catalog.sites().size()+8)/9)*9)),net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(site==null?Material.BARRIER:site.type()==AdventureQuestState.SiteType.CAMP?Material.CAMPFIRE:Material.NETHERITE_SWORD);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);String state=site==null?"Unavailable":active!=null&&active.site().equals(siteId)?"Active — click to abandon":active!=null?"Another quest is active":directory.claimed(catalog.revision(),siteId,context.character,context.partyId)?"Completed / already claimed":"Available — click to accept";var rules=settings.rules();String kind=site!=null&&site.type()==AdventureQuestState.SiteType.CHAMPION?"Champion":"Monster camp";var rewardXp=active!=null&&active.site().equals(siteId)?active.rewardXp():site!=null&&site.type()==AdventureQuestState.SiteType.CHAMPION?rules.championRewardXp():rules.campRewardXp();var rewardCoins=active!=null&&active.site().equals(siteId)?active.rewardCoins():site!=null&&site.type()==AdventureQuestState.SiteType.CHAMPION?rules.championRewardCoins():rules.campRewardCoins();meta.lore(List.of(net.kyori.adventure.text.Component.text(kind),net.kyori.adventure.text.Component.text("Reward: "+rewardXp+" XP, "+rewardCoins+" coins"),net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()),net.kyori.adventure.text.Component.text(state)));meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}player.openInventory(inventory);}));}
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();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();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(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)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)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,55 @@
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, ACTIVE, STALE }
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
public record RerollResult(RerollStatus status,Optional<AdventureQuestState.Catalog> catalog) { }
private final YamlAdventureQuestRepository repository;private final Clock clock;private final Supplier<AdventureQuestSettings.Rules> settings;private final java.util.random.RandomGenerator random;
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-adventure-persistence"));
private volatile AdventureQuestState snapshot;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public AdventureQuestDirectory(YamlAdventureQuestRepository repository,Clock clock,Supplier<AdventureQuestSettings.Rules> settings,java.util.random.RandomGenerator random)throws IOException{this.repository=Objects.requireNonNull(repository);this.clock=Objects.requireNonNull(clock);this.settings=Objects.requireNonNull(settings);this.random=Objects.requireNonNull(random);snapshot=repository.load();}
public AdventureQuestState snapshot(){return snapshot;}
public Optional<AdventureQuestState.Site> findSite(UUID id){return snapshot.sites().stream().filter(v->v.id().equals(id)).findFirst();}
public 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)).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();var quests=state.quests().stream().filter(v->!v.site().equals(id)).toList();var rosters=new HashMap<>(state.rosters());rosters.remove(id);return result(copy(state,sites,state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),rosters),found);});}
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<Optional<AdventureQuestState.Giver>> removeGiver(UUID id){return mutate(state->{var found=state.givers().stream().filter(v->v.id().equals(id)).findFirst();if(found.isEmpty())return result(state,Optional.empty());return result(copy(state,state.sites(),state.givers().stream().filter(v->!v.id().equals(id)).toList(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var catalog=roll(state,today,state.catalog()==null?Set.of():Set.copyOf(state.catalog().sites()));var next=copy(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of());return result(next,catalog);});}
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));if(!state.quests().isEmpty())return result(state,new RerollResult(RerollStatus.ACTIVE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&&current.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var next=copy(state,state.sites(),state.givers(),catalog,List.of(),sameDay?state.unitClaims():Set.of(),sameDay?state.characterClaims():Set.of(),Map.of());return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){return mutate(original->{var state=expire(original);var current=state.catalog();if(current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!current.sites().contains(site)||find(state.sites(),site).isEmpty())return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=members.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||type==AdventureQuestState.UnitType.PARTY&&q.unitType()==AdventureQuestState.UnitType.SOLO&&members.contains(q.unit()));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var rules=settings.get();var siteType=find(state.sites(),site).orElseThrow().type();var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,members,current.expiresAt(),siteType==AdventureQuestState.SiteType.CAMP?rules.campRewardXp():rules.championRewardXp(),siteType==AdventureQuestState.SiteType.CAMP?rules.campRewardCoins():rules.championRewardCoins());var quests=new ArrayList<>(state.quests());quests.add(quest);return result(copy(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters()),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
public CompletableFuture<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 alternatives=state.sites().stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=state.sites().stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);int count=Math.min(settings.get().dailySiteCount(),state.sites().size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected);}
private 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 static Optional<AdventureQuestState.Site> find(List<AdventureQuestState.Site> sites,UUID id){return sites.stream().filter(v->v.id().equals(id)).findFirst();}
private static AdventureQuestState copy(AdventureQuestState ignored,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters);}
private 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,62 @@
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) {
public enum SiteType { CAMP, CHAMPION }
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()?type==SiteType.CAMP?"Monster Camp":"Champion Site":name;}
public Arena arenaOrDefault(int radius){return arena==null?Arena.circle(radius):arena;}
}
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch) {
public Giver { Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);if(!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid facing"); }
public String displayName(){return name.isBlank()?"Quest Giver":name;}
}
public record Catalog(LocalDate day,UUID revision,Instant createdAt,List<UUID> sites) {
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 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());}
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,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);
}
}
@@ -138,10 +138,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
if(end==allOverview.size())pages.add(gearPage(record));
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
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()
@@ -157,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);
}
@@ -198,12 +216,15 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
pages.add(navigation);
}
pages.addAll(partyControls.pages(input));
if (end == allOverview.size()) pages.addAll(partyControls.pages(input));
meta.pages(pages);
item.setItemMeta(meta);
return item;
}
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
var builder = new PageBuilder();
builder.text(record.name());
@@ -219,6 +240,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
builder.text("Level: " + record.level());
builder.text("XP: " + xp);
builder.text("Unspent points: " + record.unspentPoints());
builder.text("Coins: " + record.coins());
builder.text("Health: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString());
return builder.pages();
@@ -269,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()) {
@@ -317,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(", "));
@@ -334,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()));
@@ -346,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();
}
@@ -63,26 +63,14 @@ public final class PartyBookControls implements AutoCloseable {
if (menu == Menu.ROOT) {
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
summary = summary.append(build.button("Create party", () -> mutate(input, () -> parties.create(owner).isPresent())))
.append(Component.newline()).append(build.button("Invitations", () -> show(input, Menu.INVITATIONS, 0)));
build.pages.add(summary);
build.pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} else {
var group = party.orElseThrow();
boolean leader = group.leader().equals(owner);
summary = summary.append(Component.text(group.members().size() + "/" + parties.maximumSize() + " members\n\n"))
.append(build.button("Leave party", () -> mutate(input, () -> parties.leave(owner, group.id()))));
if (leader) {
summary = summary.append(Component.newline())
.append(build.button("Disband party", () -> mutate(input, () -> parties.disband(owner, group.id()))));
if (group.members().size() < parties.maximumSize()) summary = summary.append(Component.newline())
.append(build.button("Invite players", () -> show(input, Menu.PLAYERS, 0)));
}
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size() + " active\n\nManage this party\nonly at a Party\nRegistrar."));
build.pages.add(summary);
for (var member : group.members()) {
Component row = Component.text(member.equals(group.leader()) ? "Leader\n\n" : "Member\n\n").append(names.apply(member));
if (leader && !member.equals(owner)) row = row.append(Component.text("\n\n"))
.append(build.button("Kick", () -> mutate(input, () -> parties.kick(owner, group.id(), member))));
build.pages.add(row);
for (var member : group.characters()) {
Component label=parties.activePlayer(member).map(names).orElse(Component.text("Character\n"+member.toString().substring(0,8)));
build.pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
} else if (menu == Menu.PLAYERS) {
@@ -1,5 +1,7 @@
package games.dmg.fantasystick.combat;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.presentation.CombatFeedback;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
@@ -23,6 +25,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
private static final Duration CADENCE = Duration.ofSeconds(1);
private final EquipmentSessions sessions;
private final Clock clock;
private final CombatFeedback feedback;
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Map<UUID, Instant> readyAt = new HashMap<>();
private Pending pending;
private boolean closed;
@@ -31,19 +35,31 @@ 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;
}
}
public BasicAttack(EquipmentSessions sessions) {
this(sessions, Clock.systemUTC());
this(sessions, Clock.systemUTC(), null);
}
public BasicAttack(EquipmentSessions sessions, Clock clock) {
this(sessions, clock, null);
}
public BasicAttack(EquipmentSessions sessions, Clock clock, CombatFeedback feedback) {
this(sessions, clock, feedback, games.dmg.fantasystick.training.TrainingDamage.passthrough());
}
public BasicAttack(EquipmentSessions sessions, Clock clock, CombatFeedback feedback,
games.dmg.fantasystick.training.TrainingDamage trainingDamage) {
this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock);
this.feedback = feedback;
this.trainingDamage = Objects.requireNonNull(trainingDamage);
}
@Override public void accept(ControlInput input) {
@@ -57,30 +73,43 @@ 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 {
target.damage(nativeDamage, input.player());
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
measured.ifPresent(amount -> {
attempt.accepted = true;
if (feedback != null) feedback.report("Basic Attack", input.player(), AbilityReport.Type.DAMAGE, target, amount);
});
} 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
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
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;
if (feedback != null) feedback.report("Basic Attack", attempt.input.player(), AbilityReport.Type.DAMAGE,
event.getEntity(), java.math.BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@Override public void close() {
@@ -19,12 +19,26 @@ public final class CharacterFactory {
"Hollow", "Lark", "Mist", "Reed", "Stone", "Thorn", "Vale", "Willow"};
private final Supplier<UUID> identities;
private final RandomGenerator names;
private final Supplier<games.dmg.fantasystick.progression.CoinRules> coins;
private final RandomGenerator coinRandom;
public CharacterFactory() { this(UUID::randomUUID, RandomGenerator.getDefault()); }
public CharacterFactory() { this(UUID::randomUUID, RandomGenerator.getDefault(),
games.dmg.fantasystick.progression.CoinRules::defaults, RandomGenerator.getDefault()); }
public CharacterFactory(Supplier<games.dmg.fantasystick.progression.CoinRules> coins) {
this(UUID::randomUUID, RandomGenerator.getDefault(), coins, RandomGenerator.getDefault());
}
CharacterFactory(Supplier<UUID> identities, RandomGenerator names) {
this(identities, names, games.dmg.fantasystick.progression.CoinRules::defaults, names);
}
CharacterFactory(Supplier<UUID> identities, RandomGenerator names,
Supplier<games.dmg.fantasystick.progression.CoinRules> coins, RandomGenerator coinRandom) {
this.identities = Objects.requireNonNull(identities);
this.names = Objects.requireNonNull(names);
this.coins = Objects.requireNonNull(coins);
this.coinRandom = Objects.requireNonNull(coinRandom);
}
public synchronized CharacterRecord create(ProgressionRules rules) {
@@ -34,6 +48,7 @@ public final class CharacterFactory {
+ FAMILY_NAMES[names.nextInt(FAMILY_NAMES.length)];
return new CharacterRecord(initial.characterId(), name, initial.revision(), initial.stats(),
initial.level(), initial.experience(), initial.unspentPoints(), initial.currentHealth(),
Set.of(), Collections.nCopies(7, ""), Map.of(), false, null, null);
Set.of(), Collections.nCopies(7, ""), java.util.List.of("", "", ""), Map.of(), Map.of(),
false, null, null, coins.get().starting(coinRandom), true, Set.of());
}
}
@@ -131,7 +131,7 @@ public final class EquipmentStorage {
for (int slot : EQUIPMENT_SLOTS) {
if (slot == 39) continue;
var current = inventory.getItem(slot);
if (!empty(current) && !(slot < 9 && ownedControl.test(current.clone()))) return RestoreResult.OCCUPIED_SLOT;
if (!empty(current) && !ownedControl.test(current.clone())) return RestoreResult.OCCUPIED_SLOT;
}
var currentHead = inventory.getItem(39);
var displacedHead = empty(currentHead) ? null : currentHead.clone();
@@ -0,0 +1,14 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.persistence.*;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.*;import org.bukkit.inventory.*;import org.bukkit.persistence.PersistentDataType;import org.bukkit.plugin.Plugin;
/** Native unbreakable session-bound projection of permanent character armor. */
public final class GearItems{
private final NamespacedKey typeKey,ownerKey,sessionKey,slotKey,tierKey;
public GearItems(Plugin plugin){typeKey=new NamespacedKey(plugin,"character_gear");ownerKey=new NamespacedKey(plugin,"character_gear_owner");sessionKey=new NamespacedKey(plugin,"character_gear_session");slotKey=new NamespacedKey(plugin,"character_gear_slot");tierKey=new NamespacedKey(plugin,"character_gear_tier");}
public ItemStack create(GearSlot slot,GearTier tier,UUID owner,UUID session){if(tier==GearTier.NONE)return null;var item=new ItemStack(material(slot,tier));var meta=item.getItemMeta();meta.setUnbreakable(true);meta.setMaxStackSize(1);meta.displayName(Component.text("Fantasy "+title(tier)+" "+title(slot)));var data=meta.getPersistentDataContainer();data.set(typeKey,PersistentDataType.INTEGER,1);data.set(ownerKey,PersistentDataType.STRING,owner.toString());data.set(sessionKey,PersistentDataType.STRING,session.toString());data.set(slotKey,PersistentDataType.STRING,slot.name());data.set(tierKey,PersistentDataType.STRING,tier.name());item.setItemMeta(meta);return item;}
public boolean gear(ItemStack item){return item!=null&&!item.isEmpty()&&Integer.valueOf(1).equals(item.getItemMeta().getPersistentDataContainer().get(typeKey,PersistentDataType.INTEGER));}
public boolean owned(ItemStack item,UUID owner,UUID session){if(!gear(item))return false;var data=item.getItemMeta().getPersistentDataContainer();return owner.toString().equals(data.get(ownerKey,PersistentDataType.STRING))&&session.toString().equals(data.get(sessionKey,PersistentDataType.STRING));}
public void install(PlayerInventory inventory,CharacterRecord record,UUID owner,UUID session){inventory.setChestplate(create(GearSlot.CHESTPLATE,record.gear().chestplate(),owner,session));inventory.setLeggings(create(GearSlot.LEGGINGS,record.gear().leggings(),owner,session));inventory.setBoots(create(GearSlot.BOOTS,record.gear().boots(),owner,session));}
private static String title(Enum<?> value){String text=value.name().toLowerCase(Locale.ROOT).replace('_',' ');return Character.toUpperCase(text.charAt(0))+text.substring(1);}
private static Material material(GearSlot slot,GearTier tier){return Material.valueOf((tier==GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHESTPLATE->"CHESTPLATE";case LEGGINGS->"LEGGINGS";case BOOTS->"BOOTS";});}
}
@@ -18,6 +18,7 @@ import org.bukkit.persistence.PersistentDataType;
* The caller must enforce session custody and suppress vanilla item uses; this class registers no events.
*/
public final class SessionControls {
public static final NamespacedKey COOLDOWN_MODEL = new NamespacedKey("fantasystick", "cooldown_control");
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
private final Clock clock;
private static final NamespacedKey VERSION = key("control_version");
@@ -92,6 +93,10 @@ 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);
data.set(OWNER, PersistentDataType.STRING, owner.toString());
@@ -37,9 +37,11 @@ public final class TailBooks {
meta.displayName(Component.text(fullTitle));
List<Component> pages = new ArrayList<>();
var summary = new StringBuilder(fullTitle).append("\nLevel: ").append(character.level())
.append("\nXP: ").append(character.experience()).append("\nUnspent points: ").append(character.unspentPoints());
.append("\nXP: ").append(character.experience()).append("\nUnspent points: ").append(character.unspentPoints())
.append("\nCoins: ").append(character.coins());
for (Stat stat : Stat.values()) summary.append('\n').append(stat).append(": ").append(character.stats().get(stat));
pages.addAll(BookPagination.pages(summary.toString()));
pages.addAll(BookPagination.pages("Gear\nHead: Cosmetic\nChestplate: "+tier(character.gear().chestplate())+"\nLeggings: "+tier(character.gear().leggings())+"\nBoots: "+tier(character.gear().boots())));
if (character.learned().isEmpty()) pages.addAll(BookPagination.pages("Abilities: None"));
character.learned().stream().sorted().forEach(ability -> {
var slots = new ArrayList<String>();
@@ -73,6 +75,11 @@ public final class TailBooks {
return item;
}
private static String tier(games.dmg.fantasystick.persistence.GearTier tier) {
String value = tier.name().toLowerCase(java.util.Locale.ROOT);
return Character.toUpperCase(value.charAt(0)) + value.substring(1);
}
public Optional<Identity> identify(ItemStack item) {
if (item == null || item.getType() != Material.WRITTEN_BOOK || !item.hasItemMeta()) return Optional.empty();
var data = item.getItemMeta().getPersistentDataContainer();
@@ -0,0 +1,33 @@
package games.dmg.fantasystick.party;
import java.math.BigInteger;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Durable party identity; every person reference is a transferable character UUID. */
public record CharacterParty(UUID id, BigInteger revision, String displayName, String canonicalName,
UUID leader, Set<UUID> members, List<Invitation> invitations,UUID lastPaidOperation) {
public CharacterParty(UUID id,BigInteger revision,String displayName,String canonicalName,UUID leader,Set<UUID> members,List<Invitation> invitations){this(id,revision,displayName,canonicalName,leader,members,invitations,null);}
public record Invitation(UUID id, UUID character) {
public Invitation { Objects.requireNonNull(id); Objects.requireNonNull(character); }
}
public CharacterParty {
Objects.requireNonNull(id); Objects.requireNonNull(revision); Objects.requireNonNull(displayName);
Objects.requireNonNull(canonicalName); Objects.requireNonNull(leader);
members = Set.copyOf(members); invitations = List.copyOf(invitations);
var memberIds = members;
var parsed = PartyNames.parse(displayName);
var invitationIds = new HashSet<UUID>();
var targets = new HashSet<UUID>();
if (revision.signum() < 0 || parsed.isEmpty() || !parsed.orElseThrow().canonical().equals(canonicalName)
|| !members.contains(leader) || members.isEmpty()
|| invitations.stream().anyMatch(invitation -> !invitationIds.add(invitation.id())
|| !targets.add(invitation.character()) || memberIds.contains(invitation.character()))) {
throw new IllegalArgumentException("Invalid character party");
}
}
}
@@ -0,0 +1,264 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.PartyOperationStore;
import games.dmg.fantasystick.persistence.YamlPartyRepository;
import java.io.IOException;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.IntSupplier;
/** Serialized durable character-party mutations with an immutable acknowledged read snapshot. */
public final class CharacterPartyDirectory implements AutoCloseable {
public enum Status { APPLIED, DUPLICATE, NAME_TAKEN, FULL, ALREADY_MEMBER, INVALID }
public enum Defeat { UNCHANGED, MEMBER_REMOVED, PARTY_DISBANDED }
public record Offer(UUID id, UUID party, UUID character) { }
public record PaidResult(Result result,CharacterStorage.CoinSpend spend){}
public record Result(Status status, Optional<CharacterParty> party, Optional<Offer> invitation) {
static Result of(Status status) { return new Result(status, Optional.empty(), Optional.empty()); }
static Result party(Status status, CharacterParty party) {
return new Result(status, Optional.of(party), Optional.empty());
}
static Result invite(Status status, CharacterParty party, CharacterParty.Invitation invitation) {
return new Result(status, Optional.of(party), Optional.of(new Offer(invitation.id(), party.id(), invitation.character())));
}
}
private final YamlPartyRepository repository;
private final IntSupplier maximumSize;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-party-persistence"));
private volatile List<CharacterParty> snapshot;
private volatile java.util.function.Consumer<CharacterParty> ended=ignored->{};
private volatile boolean closed;
private volatile boolean ready=true;
private volatile CompletableFuture<Void> readiness=CompletableFuture.completedFuture(null);
private int pending;
private final CompletableFuture<Void> drained=new CompletableFuture<>();
public CharacterPartyDirectory(YamlPartyRepository repository, IntSupplier maximumSize) throws IOException {
this.repository = Objects.requireNonNull(repository);
this.maximumSize = Objects.requireNonNull(maximumSize);
snapshot = repository.load();
maximumSize();
}
public List<CharacterParty> all(){return snapshot;}
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 Optional<CharacterParty> partyOf(UUID character) {
return snapshot.stream().filter(party -> party.members().contains(character)).findFirst();
}
public Optional<CharacterParty> partyById(UUID id) {
return snapshot.stream().filter(party -> party.id().equals(id)).findFirst();
}
public Optional<CharacterParty> partyByCanonicalName(String canonical) {
return snapshot.stream().filter(party -> party.canonicalName().equals(canonical)).findFirst();
}
public List<Offer> invitationsFor(UUID character) {
return snapshot.stream().flatMap(party -> party.invitations().stream()
.filter(invitation -> invitation.character().equals(character))
.map(invitation -> new Offer(invitation.id(), party.id(), character)))
.sorted(Comparator.comparing(Offer::id)).toList();
}
public CompletableFuture<Result> create(UUID leader, String input) {
return create(UUID.randomUUID(),leader,input);
}
public CompletableFuture<Result> create(UUID partyId, UUID leader, String input) {
return submit(() -> createNow(partyId,leader,input));
}
private Result createNow(UUID partyId,UUID leader,String input)throws IOException{
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);
if(partyByCanonicalName(name.orElseThrow().canonical()).isPresent())return Result.of(Status.NAME_TAKEN);
var party=new CharacterParty(partyId,BigInteger.ZERO,name.orElseThrow().display(),name.orElseThrow().canonical(),leader,Set.of(leader),List.of());var next=new ArrayList<>(snapshot);next.add(party);save(next);return Result.party(Status.APPLIED,party);
}
public CompletableFuture<PaidResult> paidCreate(PartyOperationStore.Plan plan,CharacterStorage characters){return submit(()->{
if(plan.kind()!=PartyOperationStore.Kind.CREATE)throw new IllegalArgumentException("Expected create operation");
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);
if(partyByCanonicalName(name.orElseThrow().canonical()).isPresent())return new PaidResult(Result.of(Status.NAME_TAKEN),null);
var spent=debit(plan,characters,plan.expectedRevision());if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE)return new PaidResult(Result.of(Status.INVALID),spent);if(refunded(plan,spent))return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));
var party=new CharacterParty(plan.id(),BigInteger.ZERO,name.orElseThrow().display(),name.orElseThrow().canonical(),plan.actor(),Set.of(plan.actor()),List.of(),plan.id());var next=new ArrayList<>(snapshot);next.add(party);try{save(next);return new PaidResult(Result.party(Status.APPLIED,party),spent);}catch(IOException persistenceFailure){return compensate(plan,characters,persistenceFailure);}
});}
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);
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());
var invitation = new CharacterParty.Invitation(UUID.randomUUID(), target);
var invitations = new ArrayList<>(party.orElseThrow().invitations()); invitations.add(invitation);
var updated = copy(party.orElseThrow(), party.orElseThrow().leader(), party.orElseThrow().members(), invitations);
replace(party.orElseThrow(), updated);
return Result.invite(Status.APPLIED, updated, invitation);
});
}
public CompletableFuture<Result> accept(UUID character, UUID invitationId) {
return submit(() -> {
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; }
}
if (party == null || !invitation.character().equals(character)) return Result.of(Status.INVALID);
if (partyOf(character).isPresent()) return Result.of(Status.ALREADY_MEMBER);
if (party.members().size() >= maximumSize()) return Result.of(Status.FULL);
var members = new java.util.HashSet<>(party.members()); members.add(character);
var updated = copy(party, party.leader(), members,
party.invitations().stream().filter(value -> !value.id().equals(invitationId)).toList());
var next = new ArrayList<CharacterParty>();
for (var candidate : snapshot) {
var base = candidate.id().equals(party.id()) ? updated : candidate;
var offers = base.invitations().stream().filter(value -> !value.character().equals(character)).toList();
next.add(offers.size() == base.invitations().size() ? base
: copy(base, base.leader(), base.members(), offers));
}
save(next);
return Result.party(Status.APPLIED, partyById(party.id()).orElseThrow());
});
}
public CompletableFuture<Result> decline(UUID character, UUID invitationId) {
return submit(() -> {
for (var party : snapshot) {
var invitation = party.invitations().stream().filter(value -> value.id().equals(invitationId)).findFirst();
if (invitation.isEmpty() || !invitation.orElseThrow().character().equals(character)) continue;
var updated = copy(party, party.leader(), party.members(),
party.invitations().stream().filter(value -> !value.id().equals(invitationId)).toList());
replace(party, updated); return Result.party(Status.APPLIED, updated);
}
return Result.of(Status.INVALID);
});
}
public CompletableFuture<Result> cancel(UUID leader, UUID partyId, UUID invitationId) {
return submit(() -> {
var party = managed(leader, partyId);
if (party.isEmpty() || party.orElseThrow().invitations().stream().noneMatch(value -> value.id().equals(invitationId))) {
return Result.of(Status.INVALID);
}
var updated = copy(party.orElseThrow(), leader, party.orElseThrow().members(),
party.orElseThrow().invitations().stream().filter(value -> !value.id().equals(invitationId)).toList());
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
});
}
public CompletableFuture<Result> kick(UUID leader, UUID partyId, UUID member) {
return submit(() -> {
var party = managed(leader, partyId);
if (party.isEmpty() || member.equals(leader) || !party.orElseThrow().members().contains(member)) {
return Result.of(Status.INVALID);
}
var members = new java.util.HashSet<>(party.orElseThrow().members()); members.remove(member);
var updated = copy(party.orElseThrow(), leader, members, party.orElseThrow().invitations());
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
});
}
public CompletableFuture<Result> leave(UUID character, UUID partyId) {
return submit(() -> {
var party = partyOf(character).filter(value -> value.id().equals(partyId));
if (party.isEmpty()) return Result.of(Status.INVALID);
if (party.orElseThrow().leader().equals(character)) {
remove(party.orElseThrow()); return Result.party(Status.APPLIED, party.orElseThrow());
}
var members = new java.util.HashSet<>(party.orElseThrow().members()); members.remove(character);
var updated = copy(party.orElseThrow(), party.orElseThrow().leader(), members,
party.orElseThrow().invitations());
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
});
}
public CompletableFuture<Result> disband(UUID leader, UUID partyId) {
return submit(() -> {
var party = managed(leader, partyId);
if (party.isEmpty()) return Result.of(Status.INVALID);
remove(party.orElseThrow()); return Result.party(Status.APPLIED, party.orElseThrow());
});
}
public CompletableFuture<Result> transfer(UUID leader, UUID partyId, UUID target) {
return submit(() -> {
var existing=partyById(partyId);
if(existing.isPresent()&&existing.orElseThrow().leader().equals(target)
&&existing.orElseThrow().members().contains(leader))return Result.party(Status.DUPLICATE,existing.orElseThrow());
var party = managed(leader, partyId);
if (party.isEmpty() || target.equals(leader) || !party.orElseThrow().members().contains(target)) {
return Result.of(Status.INVALID);
}
var updated = copy(party.orElseThrow(), target, party.orElseThrow().members(), party.orElseThrow().invitations());
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
});
}
public CompletableFuture<PaidResult> paidTransfer(PartyOperationStore.Plan plan,CharacterStorage characters){return submit(()->{
if(plan.kind()!=PartyOperationStore.Kind.TRANSFER)throw new IllegalArgumentException("Expected transfer operation");
var existing=partyById(plan.party());
if(existing.isPresent()&&plan.id().equals(existing.orElseThrow().lastPaidOperation())){var spent=debit(plan,characters,plan.expectedRevision());if(refunded(plan,spent)){var reverted=new CharacterParty(existing.orElseThrow().id(),existing.orElseThrow().revision().add(BigInteger.ONE),existing.orElseThrow().displayName(),existing.orElseThrow().canonicalName(),plan.actor(),existing.orElseThrow().members(),existing.orElseThrow().invitations(),null);replace(existing.orElseThrow(),reverted);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);}
if(existing.isEmpty()||!existing.orElseThrow().revision().equals(plan.expectedPartyRevision()))return new PaidResult(Result.of(Status.INVALID),null);
var party=managed(plan.actor(),plan.party());if(party.isEmpty()||plan.target().equals(plan.actor())||!party.orElseThrow().members().contains(plan.target()))return new PaidResult(Result.of(Status.INVALID),null);
var spent=debit(plan,characters,plan.expectedRevision());if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE)return new PaidResult(Result.of(Status.INVALID),spent);if(refunded(plan,spent))return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));
var updated=new CharacterParty(party.orElseThrow().id(),party.orElseThrow().revision().add(BigInteger.ONE),party.orElseThrow().displayName(),party.orElseThrow().canonicalName(),plan.target(),party.orElseThrow().members(),party.orElseThrow().invitations(),plan.id());try{replace(party.orElseThrow(),updated);}catch(IOException persistenceFailure){return compensate(plan,characters,persistenceFailure);}return new PaidResult(Result.party(Status.APPLIED,updated),spent);
});}
private CharacterStorage.CoinSpend debit(PartyOperationStore.Plan plan,CharacterStorage characters,BigInteger expected){return characters.spendCoins(plan.actor(),expected,plan.id(),plan.amount()).join();}
private static boolean refunded(PartyOperationStore.Plan plan,CharacterStorage.CoinSpend spent){return spent.record().map(value->value.rewardReceipts().contains(PartyOperationStore.refundId(plan.id()))).orElse(false);}
private PaidResult compensate(PartyOperationStore.Plan plan,CharacterStorage characters,IOException failure)throws IOException{var refund=characters.refundCoins(plan.actor(),plan.id(),PartyOperationStore.refundId(plan.id()),plan.amount()).join();if(refund.status()==CharacterStorage.CoinRefundStatus.APPLIED||refund.status()==CharacterStorage.CoinRefundStatus.DUPLICATE)return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,refund.record()));throw failure;}
public CompletableFuture<Defeat> defeat(UUID character) {
return submit(() -> {
var membership=partyOf(character);var endedParty=membership.filter(value->value.leader().equals(character)).orElse(null);var next=new ArrayList<CharacterParty>();boolean changed=false;
for(var party:snapshot){if(endedParty!=null&&party.id().equals(endedParty.id())){changed=true;continue;}var members=party.members();if(membership.isPresent()&&party.id().equals(membership.orElseThrow().id())){var reduced=new java.util.HashSet<>(members);reduced.remove(character);members=Set.copyOf(reduced);changed=true;}var invitations=party.invitations().stream().filter(value->!value.character().equals(character)).toList();if(invitations.size()!=party.invitations().size())changed=true;next.add(members.equals(party.members())&&invitations.equals(party.invitations())?party:copy(party,party.leader(),members,invitations));}
if(changed)save(next);if(endedParty!=null){try{ended.accept(endedParty);}catch(RuntimeException ignored){}return Defeat.PARTY_DISBANDED;}return membership.isPresent()?Defeat.MEMBER_REMOVED:Defeat.UNCHANGED;
});
}
private Optional<CharacterParty> managed(UUID leader, UUID id) {
return partyById(id).filter(party -> party.leader().equals(leader));
}
private CharacterParty copy(CharacterParty old, UUID leader, Set<UUID> members,
List<CharacterParty.Invitation> invitations) {
return new CharacterParty(old.id(), old.revision().add(BigInteger.ONE), old.displayName(), old.canonicalName(),
leader, members, invitations,old.lastPaidOperation());
}
private void replace(CharacterParty old, CharacterParty updated) throws IOException {
var next = snapshot.stream().map(party -> party.id().equals(old.id()) ? updated : party).toList(); save(next);
}
private void remove(CharacterParty party) throws IOException {
save(snapshot.stream().filter(candidate -> !candidate.id().equals(party.id())).toList());
try{ended.accept(party);}catch(RuntimeException ignored){}
}
private void save(List<CharacterParty> next) throws IOException {
repository.save(next); snapshot = List.copyOf(next);
}
private int maximumSize() {
int value = maximumSize.getAsInt(); if (value < 1) throw new IllegalStateException("Party maximum must be positive");
return value;
}
private <T> CompletableFuture<T> submit(Callable<T> operation) {
var future = new CompletableFuture<T>();
synchronized(this){if (closed) { future.completeExceptionally(new IllegalStateException("Party directory is closed")); return future; }pending++;}
worker.execute(() -> { try { future.complete(operation.call()); } catch (Throwable failure) { future.completeExceptionally(failure); } finally{finished();} });
return future;
}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
public java.util.concurrent.CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close() { closed = true; worker.shutdown();if(pending==0)drained.complete(null); }
}
@@ -39,7 +39,8 @@ import org.bukkit.event.entity.PlayerDeathEvent;
/** Accepted native contribution capture and durable character-bound XP delivery. */
public final class CombatExperience implements Listener, AutoCloseable {
public record Failure(UUID character, Throwable cause) { }
private record Defeat(EquipmentSessions.Active active, int level) { }
private record Defeat(EquipmentSessions.Active active, int level, BigInteger coins) { }
private record Eligible(UUID character, UUID player) { }
private static final Set<EntityType> PASSIVE = Set.of(
EntityType.ALLAY, EntityType.ARMADILLO, EntityType.AXOLOTL, EntityType.BAT, EntityType.CAMEL,
EntityType.CAT, EntityType.CHICKEN, EntityType.COD, EntityType.COPPER_GOLEM, EntityType.COW,
@@ -49,10 +50,14 @@ public final class CombatExperience implements Listener, AutoCloseable {
EntityType.SKELETON_HORSE, EntityType.SNIFFER, EntityType.SNOW_GOLEM, EntityType.SQUID,
EntityType.STRIDER, EntityType.TADPOLE, EntityType.TROPICAL_FISH, EntityType.TURTLE,
EntityType.VILLAGER, EntityType.WANDERING_TRADER, EntityType.ZOMBIE_HORSE);
private static final Set<EntityType> NEUTRAL = Set.of(
EntityType.BEE, EntityType.CAVE_SPIDER, EntityType.DOLPHIN, EntityType.ENDERMAN, EntityType.GOAT,
EntityType.IRON_GOLEM, EntityType.LLAMA, EntityType.PANDA, EntityType.PIGLIN, EntityType.POLAR_BEAR,
EntityType.SPIDER, EntityType.TRADER_LLAMA, EntityType.WOLF, EntityType.ZOMBIFIED_PIGLIN);
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;
@@ -60,34 +65,61 @@ public final class CombatExperience implements Listener, AutoCloseable {
private final Consumer<Failure> failures;
private final CombatSources sources;
private final CombatContributions contributions;
private final PartyRoster parties;
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
private final java.util.random.RandomGenerator random;
private final Map<PlayerDeathEvent, Defeat> defeats = new IdentityHashMap<>();
private final Set<UUID> pvpDefeats = java.util.concurrent.ConcurrentHashMap.newKeySet();
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 {
final UUID id = UUID.randomUUID();
final UUID id;
final UUID character;
final BigInteger reward;
final BigInteger experience;
final BigInteger coins;
final CombatRewardRules.KillPosition restricted;
final CombatRewardRules rewardRules;
BigInteger expectedRevision;
boolean running;
PendingAward(UUID character, BigInteger reward, CombatRewardRules.KillPosition restricted) {
this.character = character; this.reward = reward; this.restricted = restricted;
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
this.id = id; this.character = character; this.experience = experience;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
}
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
EquipmentSessions sessions, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures) {
this(characters, progression, rewards, sessions, new PartyRoster(clock, id -> {
var player = players.apply(id); return player != null && player.isOnline();
}), games.dmg.fantasystick.progression.CoinRules::defaults, java.util.random.RandomGenerator.getDefault(),
clock, nextTick, players, failures);
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
EquipmentSessions sessions, PartyRoster parties,
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
java.util.random.RandomGenerator random, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures) {
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.players = Objects.requireNonNull(players); this.failures = Objects.requireNonNull(failures);
this.parties = Objects.requireNonNull(parties); this.coinRules = Objects.requireNonNull(coinRules);
this.random = Objects.requireNonNull(random); this.clock = Objects.requireNonNull(clock);
this.nextTick = Objects.requireNonNull(nextTick); this.players = Objects.requireNonNull(players);
this.failures = Objects.requireNonNull(failures);
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);
@@ -109,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)
@@ -122,20 +154,33 @@ public final class CombatExperience implements Listener, AutoCloseable {
if (closed) return;
var active = sessions.active(event.getEntity().getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
if (record != null) defeats.put(event, new Defeat(active, record.level()));
if (record != null) {
defeats.put(event, new Defeat(active, record.level(), record.coins()));
if (contributions.hasDirect(event.getEntity().getUniqueId(), record.id(), clock.instant())) {
pvpDefeats.add(event.getEntity().getUniqueId());
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void playerDeath(PlayerDeathEvent event) {
var defeat = defeats.remove(event);
if (closed || defeat == null || !event.isCancelled() || sessions.current(defeat.active())) return;
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER, defeat.level(), defeat.active().character());
if (closed || defeat == null) return;
if (!event.isCancelled() || sessions.current(defeat.active())) {
pvpDefeats.remove(event.getEntity().getUniqueId());
return;
}
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER,
defeat.level(), defeat.active().character(), defeat.coins());
}
/** Consumed synchronously by the fantasy defeat transition before it persists the Tail state. */
public boolean consumePvpDefeat(UUID player) { return pvpDefeats.remove(player); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void mobDeath(EntityDeathEvent event) {
if (closed || event instanceof PlayerDeathEvent) return;
award(event.getEntity(), classify(event.getEntity()), 0, null);
if (closed || event instanceof PlayerDeathEvent || excluded.test(event.getEntity())) return;
award(event.getEntity(), classify(event.getEntity()), 0, null, BigInteger.ZERO);
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -145,21 +190,62 @@ public final class CombatExperience implements Listener, AutoCloseable {
}
}
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter) {
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter,
BigInteger defeatedCoins) {
Instant now = clock.instant();
var contributors = new ArrayList<>(contributions.consume(target.getUniqueId(), now));
if (defeatedCharacter != null) contributors.remove(defeatedCharacter);
if (contributors.isEmpty()) return;
BigInteger share = rewards.share(rewards.pool(kind, level), contributors.size());
var direct = new ArrayList<>(contributions.consumeDirect(target.getUniqueId(), now));
if (defeatedCharacter != null) direct.remove(defeatedCharacter);
if (direct.isEmpty()) return;
var units = rewardUnits(direct);
BigInteger coinPool = defeatedCharacter == null
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
if (units.isEmpty()) return; // The defeat transition has already persisted any PvP balance loss.
var 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());
for (UUID character : contributors) {
var operation = new PendingAward(character, share, restricted);
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,
capturedRewards);
pending.put(operation.id, operation);
attempt(operation);
}
}
private java.util.List<games.dmg.fantasystick.progression.RewardAllocator.Unit<UUID>> rewardUnits(
java.util.List<UUID> direct) {
var units = new java.util.LinkedHashMap<Object, games.dmg.fantasystick.progression.RewardAllocator.Unit<UUID>>();
direct.stream().sorted().forEach(character -> eligible(character).ifPresent(attacker -> {
var membership = parties.rewardMembership(attacker.player());
if (membership.isEmpty()) {
units.putIfAbsent(character, new games.dmg.fantasystick.progression.RewardAllocator.Unit<>(character,
java.util.List.of(character)));
return;
}
var group = membership.orElseThrow();
var recipients = group.activePlayers().stream().map(player -> sessions.active(player).orElse(null))
.filter(Objects::nonNull).filter(sessions::current)
.filter(active -> living(active.player())).map(EquipmentSessions.Active::character).distinct().sorted().toList();
if (!recipients.isEmpty()) units.putIfAbsent(group.key(),
new games.dmg.fantasystick.progression.RewardAllocator.Unit<>(group.key(), recipients));
}));
return java.util.List.copyOf(units.values());
}
private java.util.Optional<Eligible> eligible(UUID character) {
var active = sessions.activeCharacter(character).orElse(null);
if (active == null || !sessions.current(active) || !living(active.player())) return java.util.Optional.empty();
return java.util.Optional.of(new Eligible(character, active.player()));
}
private boolean living(UUID playerId) {
var player = players.apply(playerId);
return player != null; // The current active live-record session is the online/living authority.
}
private static CombatRewardRules.KillPosition position(Location location) {
var world = Objects.requireNonNull(location.getWorld(), "Kill location has no world").getUID();
return new CombatRewardRules.KillPosition(Objects.requireNonNull(world, "Kill world has no UUID"),
@@ -181,8 +267,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
});
return;
}
characters.awardCombatExperience(operation.character, operation.expectedRevision, operation.reward,
progression, rewards, operation.restricted).whenComplete((award, failure) -> {
characters.awardCombatReward(operation.character, operation.expectedRevision, operation.id,
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()) {
@@ -223,7 +309,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
Player player = active == null ? null : players.apply(active.player());
if (player == null || !player.isOnline()) return;
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
player.sendMessage("Earned " + operation.reward + " fantasy XP.");
if (operation.experience.signum() > 0) player.sendMessage("Earned " + operation.experience + " fantasy XP.");
for (int gained = 1; gained <= award.levels(); gained++) {
int level = saved.level() - award.levels() + gained;
player.sendMessage("Level " + level + "! Gained 1 character point.");
@@ -237,14 +323,35 @@ public final class CombatExperience implements Listener, AutoCloseable {
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
}
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE) {
player.sendMessage("Earned " + operation.coins + " character coins.");
}
}
static CombatRewardRules.Target classify(LivingEntity entity) {
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;
}
public static games.dmg.fantasystick.progression.CoinRules.Target coinTarget(LivingEntity entity) {
EntityType type = entity.getType();
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON || type == EntityType.WITHER) {
return games.dmg.fantasystick.progression.CoinRules.Target.BOSS;
}
if (PASSIVE.contains(type)) return games.dmg.fantasystick.progression.CoinRules.Target.PASSIVE;
if (NEUTRAL.contains(type)) return games.dmg.fantasystick.progression.CoinRules.Target.NEUTRAL;
return games.dmg.fantasystick.progression.CoinRules.Target.HOSTILE;
}
private void report(UUID character, Throwable failure) {
try { failures.accept(new Failure(character, failure)); }
catch (RuntimeException ignored) { /* Diagnostics never replay or acknowledge a reward. */ }
@@ -253,6 +360,6 @@ public final class CombatExperience implements Listener, AutoCloseable {
@Override public void close() {
closed = true;
if (retryTask != null) retryTask.cancel();
defeats.clear(); pending.clear(); retries.clear(); contributions.close();
defeats.clear(); pvpDefeats.clear(); pending.clear(); retries.clear(); contributions.close();
}
}
@@ -0,0 +1,36 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.persistence.*;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Registrar-authorized mutations with durable operation plans spanning coin and party files. */
public final class PartyManagement implements AutoCloseable {
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED}
public record Outcome(Status status,Optional<CharacterParty> party){}
private record Context(EquipmentSessions.Active active,CharacterRecord record,PartyRegistrar registrar){}
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
public PartyManagement(CharacterPartyDirectory parties,PartyRegistrarDirectory registrars,CharacterStorage characters,EquipmentSessions sessions,PartyEconomySettings prices,PartyOperationStore operations,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.parties=Objects.requireNonNull(parties);this.registrars=Objects.requireNonNull(registrars);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.prices=Objects.requireNonNull(prices);this.operations=Objects.requireNonNull(operations);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);parties.readiness().thenCompose(ignored->operations.load()).thenCompose(plans->java.util.concurrent.CompletableFuture.allOf(plans.stream().map(plan->execute(plan,null,null).toCompletableFuture()).toArray(java.util.concurrent.CompletableFuture[]::new))).whenComplete((ignored,failure)->{if(failure==null){recovered=true;recovery.complete(null);}else{report(failure);recovery.completeExceptionally(failure);}});}
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var parsed=PartyNames.parse(name);if(parsed.isEmpty()){result.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}var captured=context.orElseThrow();if(parties.partyOf(captured.record().id()).isPresent()){result.accept(new Outcome(Status.ALREADY_MEMBER,Optional.empty()));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){result.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,result);}
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,result);}
private void prepare(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){operations.prepare(plan).whenComplete((saved,failure)->{if(failure!=null){fail(failure,result);return;}execute(saved,context,result);});}
private java.util.concurrent.CompletionStage<Void> execute(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){var done=new java.util.concurrent.CompletableFuture<Void>();parties.readiness().thenCompose(ignored->plan.kind()==PartyOperationStore.Kind.CREATE?parties.paidCreate(plan,characters):parties.paidTransfer(plan,characters)).whenComplete((paid,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}var spent=paid.spend();var changed=paid.result();if(spent!=null&&(spent.status()==CharacterStorage.CoinSpendStatus.INSUFFICIENT||spent.status()==CharacterStorage.CoinSpendStatus.UNAVAILABLE)){finish(plan,context,result,spent,new Outcome(Status.INSUFFICIENT,Optional.empty()),done);return;}if(spent==null){finish(plan,context,result,null,new Outcome(map(changed.status()),changed.party()),done);return;}if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE){finish(plan,context,result,spent,new Outcome(Status.FAILED,Optional.empty()),done);return;}if(changed.status()!=CharacterPartyDirectory.Status.APPLIED&&changed.status()!=CharacterPartyDirectory.Status.DUPLICATE){finish(plan,context,result,spent,new Outcome(map(changed.status()),changed.party()),done);return;}finish(plan,context,result,spent,new Outcome(Status.APPLIED,changed.party()),done);});return done.minimalCompletionStage();}
private void finish(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result,CharacterStorage.CoinSpend spent,Outcome outcome,java.util.concurrent.CompletableFuture<Void> done){operations.complete(plan.id()).whenComplete((ignored,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}if(result!=null)publish(()->{if(context!=null&&spent!=null&&spent.record().isPresent())sessions.acknowledgeProgress(context.active(),context.record().revision(),spent.record().orElseThrow());result.accept(outcome);});done.complete(null);});}
private static Status map(CharacterPartyDirectory.Status status){return switch(status){case NAME_TAKEN->Status.NAME_TAKEN;case ALREADY_MEMBER->Status.ALREADY_MEMBER;default->Status.FAILED;};}
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null||!party.leader().equals(context.orElseThrow().record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(context.orElseThrow().record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,result));}
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.accept(c.record().id(),invitation),result);}public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.decline(c.record().id(),invitation),result);}public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.cancel(c.record().id(),p.id(),invitation),result);}public void leave(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.leave(c.record().id(),p.id()),result);}public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.kick(c.record().id(),p.id(),target),result);}public void disband(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.disband(c.record().id(),p.id()),result);}
public Optional<CharacterParty> party(Player player,UUID registrar){return context(player,registrar).flatMap(value->parties.partyOf(value.record().id()));}public List<CharacterPartyDirectory.Offer> invitations(Player player,UUID registrar){return context(player,registrar).map(value->parties.invitationsFor(value.record().id())).orElse(List.of());}public List<CharacterPartyDirectory.Offer> outgoingInvitations(Player player,UUID registrar){var context=context(player,registrar);if(context.isEmpty())return List.of();var party=parties.partyOf(context.orElseThrow().record().id()).filter(value->value.leader().equals(context.orElseThrow().record().id())).orElse(null);return party==null?List.of():party.invitations().stream().map(value->new CharacterPartyDirectory.Offer(value.id(),party.id(),value.character())).toList();}
private void mutate(Player player,UUID id,java.util.function.Function<Context,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}try{action.apply(context.orElseThrow()).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
private void mutateParty(Player player,UUID id,java.util.function.BiFunction<Context,CharacterParty,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
private void complete(CharacterPartyDirectory.Result value,Throwable failure,Consumer<Outcome> result){if(failure!=null){fail(failure,result);return;}publish(()->result.accept(new Outcome(value.status()==CharacterPartyDirectory.Status.APPLIED?Status.APPLIED:map(value.status()),value.party())));}
public int registrationPrice(){return prices.registrationPrice();}public int leaderChangePrice(){return prices.leaderChangePrice();}
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
private Optional<Context> context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));}
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
public java.util.concurrent.CompletionStage<Void> ready(){return recovery.minimalCompletionStage();}
public java.util.concurrent.CompletionStage<Void> drained(){return operations.drained();}
@Override public void close(){closed=true;operations.close();}
}
@@ -0,0 +1,28 @@
package games.dmg.fantasystick.party;
import java.text.Normalizer;
import java.util.Locale;
import java.util.Optional;
/** Validated display and globally comparable identities for persistent parties. */
public final class PartyNames {
public record Name(String display, String canonical) { }
private PartyNames() { }
public static Optional<Name> parse(String input) {
if (input == null) return Optional.empty();
String display = Normalizer.normalize(input.strip(), Normalizer.Form.NFC);
int length = display.codePointCount(0, display.length());
if (length < 1 || length > 32 || display.codePoints().anyMatch(PartyNames::forbidden)) {
return Optional.empty();
}
String canonical = Normalizer.normalize(display.toLowerCase(Locale.ROOT), Normalizer.Form.NFC);
return Optional.of(new Name(display, canonical));
}
private static boolean forbidden(int codePoint) {
int type = Character.getType(codePoint);
return type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE;
}
}
@@ -0,0 +1,19 @@
package games.dmg.fantasystick.party;
import java.util.Objects;
import java.util.UUID;
/** Durable desired location for one administrator-managed Party Registrar. */
public record PartyRegistrar(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch) {
public PartyRegistrar {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(world);
name = name.strip();
if (name.codePointCount(0, name.length()) > 64 || name.codePoints().anyMatch(codePoint -> {
int type = Character.getType(codePoint);
return type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE;
}) || !Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
throw new IllegalArgumentException("Invalid Party Registrar");
}
}
}
@@ -0,0 +1,46 @@
package games.dmg.fantasystick.party;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BiConsumer;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Villager;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
/** Loaded-chunk reconciliation and native protection for distinct Party Registrar villagers. */
public final class PartyRegistrarController implements Listener, AutoCloseable {
private final Plugin plugin; private final PartyRegistrarDirectory directory;
private final BiConsumer<Player,UUID> interactions; private final NamespacedKey idKey,typeKey;
private final java.util.Set<Entity> relocating=java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private boolean closed;
public PartyRegistrarController(Plugin plugin,PartyRegistrarDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"party_registrar_id");typeKey=new NamespacedKey(plugin,"managed_npc_type");}
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}
public boolean tagged(Entity entity){return "party_registrar".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,PartyRegistrar>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&&chunk(value.x())==chunk.getX()&&chunk(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawn(value);}
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
private void spawn(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=chunk(value.x()),z=chunk(value.z());if(!world.isChunkLoaded(x,z))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
private void configure(Villager villager,PartyRegistrar value){villager.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,"party_registrar");villager.getPersistentDataContainer().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.name().isBlank()?"Party Registrar":value.name()));villager.setCustomNameVisible(true);}
private Location location(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());return new Location(world,value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
private UUID id(Entity entity){String value=entity.getPersistentDataContainer().get(idKey,PersistentDataType.STRING);try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
private static int chunk(double coordinate){return ((int)Math.floor(coordinate))>>4;}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEntityEvent event){if(!tagged(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=org.bukkit.inventory.EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked());if(id!=null&&directory.find(id).isPresent())interactions.accept(event.getPlayer(),id);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(org.bukkit.event.entity.EntityCombustEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(org.bukkit.event.entity.EntityTransformEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(org.bukkit.event.entity.EntityBreedEvent event){if(tagged(event.getMother())||tagged(event.getFather()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(tagged(event.getEntered()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(org.bukkit.event.entity.PlayerLeashEntityEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.entity.EntityTeleportEvent event){if(tagged(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR)public void chunk(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
@Override public void close(){closed=true;for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity))entity.remove();}}
}
@@ -0,0 +1,29 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/** Serialized durable desired state for Party Registrar entities. */
public final class PartyRegistrarDirectory implements AutoCloseable {
private final YamlPartyRegistrarRepository repository;
private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-party-registrar-persistence"));
private volatile List<PartyRegistrar> snapshot; private volatile boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public PartyRegistrarDirectory(YamlPartyRegistrarRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}
public List<PartyRegistrar> all(){return snapshot;}
public Optional<PartyRegistrar> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
public CompletableFuture<PartyRegistrar> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{var value=new PartyRegistrar(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;});}
public CompletableFuture<Optional<PartyRegistrar>> remove(UUID id){return submit(()->{var found=find(id);if(found.isEmpty())return Optional.empty();var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;});}
public CompletableFuture<PartyRegistrar> restore(PartyRegistrar registrar){return submit(()->{var found=find(registrar.id());if(found.isPresent()){if(found.orElseThrow().equals(registrar))return registrar;throw new IOException("Registrar identity collision");}var next=new ArrayList<>(snapshot);next.add(registrar);next.sort(java.util.Comparator.comparing(value->value.id().toString()));repository.save(next);snapshot=List.copyOf(next);return registrar;});}
private <T> CompletableFuture<T> submit(Callable<T> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Registrar directory closed"));return future;}pending++;}worker.execute(()->{try{future.complete(operation.call());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public java.util.concurrent.CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,35 @@
package games.dmg.fantasystick.party;
import java.util.*;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
/** Plugin-owned bounded paginated Registrar menus; no chat or remote book mutation path. */
public final class PartyRegistrarMenus implements Listener,AutoCloseable {
private enum Screen{ROOT,NAME,INVITATIONS,OUTGOING,INVITE,MEMBERS}
private record Holder(UUID registrar,UUID character,UUID session,Screen screen,int page) implements InventoryHolder{public Inventory getInventory(){return null;}}
private final Server server;private final PartyManagement management;private final NamespacedKey actionKey,targetKey;private boolean closed;
public PartyRegistrarMenus(Plugin plugin,PartyManagement management){server=plugin.getServer();this.management=Objects.requireNonNull(management);actionKey=new NamespacedKey(plugin,"party_registrar_action");targetKey=new NamespacedKey(plugin,"party_registrar_target");}
public void open(Player player,UUID registrar){open(player,registrar,Screen.ROOT,0);}
private void open(Player player,UUID registrar,Screen screen,int page){if(closed)return;var record=management.active(player,registrar);if(record.isEmpty()){player.sendMessage("You must be actively playing a living fantasy character beside this Party Registrar.");return;}if(screen==Screen.NAME){openName(player,registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow());return;}var inventory=server.createInventory(new Holder(registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow(),screen,page),screen==Screen.ROOT?27:54,net.kyori.adventure.text.Component.text("Party Registrar"));var party=management.party(player,registrar);if(screen==Screen.ROOT){if(party.isEmpty()){inventory.setItem(11,item(Material.WRITABLE_BOOK,"Register a party — "+management.registrationPrice()+" coins","register",null));inventory.setItem(15,item(Material.MAP,"Invitations","invitations",null));}else{var group=party.orElseThrow();inventory.setItem(4,item(Material.PAPER,"Party: "+group.displayName(),"info",null));if(group.leader().equals(record.orElseThrow().id())){inventory.setItem(10,item(Material.EMERALD,"Invite active characters","invite",null));inventory.setItem(13,item(Material.PLAYER_HEAD,"Manage members","members",null));inventory.setItem(16,item(Material.BARRIER,"Disband party","disband",null));inventory.setItem(22,item(Material.MAP,"Pending invitations","outgoing",null));}else inventory.setItem(13,item(Material.BARRIER,"Leave party","leave",null));}}else if(screen==Screen.INVITATIONS){var values=management.invitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Invitation (left accept, right decline)","invitation",value.id()));}else if(screen==Screen.OUTGOING){var values=management.outgoingInvitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Cancel invitation for "+value.character(),"cancel-invitation",value.id()));}else if(screen==Screen.INVITE){var values=server.getOnlinePlayers().stream().filter(Player::isOnline).map(target->management.activeCharacter(target).orElse(null)).filter(Objects::nonNull).filter(target->management.eligibleInvitationTarget(target.id())).map(value->value.id()).distinct().sorted().toList();fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Invite "+value,"invite-target",value));}else{var values=party.map(value->value.members().stream().filter(member->!member.equals(value.leader())).sorted().toList()).orElse(List.of());fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Member "+value,"member",value,List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — "+management.leaderChangePrice()+" coins"))));}player.openInventory(inventory);}
private <T> void fill(Inventory inventory,int page,List<T> values,java.util.function.Function<T,ItemStack> item){int start=Math.max(0,page)*45;for(int index=start;index<Math.min(values.size(),start+45);index++)inventory.setItem(index-start,item.apply(values.get(index)));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","previous",null));if(start+45<values.size())inventory.setItem(53,item(Material.ARROW,"Next","next",null));}
private void openName(Player player,UUID registrar,UUID character,UUID session){var inventory=server.createInventory(new Holder(registrar,character,session,Screen.NAME,0),org.bukkit.event.inventory.InventoryType.ANVIL,net.kyori.adventure.text.Component.text("Choose party name"));inventory.setItem(0,item(Material.PAPER,"Party name","name",null));player.openInventory(inventory);}
private ItemStack item(Material material,String name,String action,UUID target){return item(material,name,action,target,List.of());}
private ItemStack item(Material material,String name,String action,UUID target,List<net.kyori.adventure.text.Component> lore){var item=new ItemStack(material);var meta=item.getItemMeta();meta.customName(net.kyori.adventure.text.Component.text(name));if(!lore.isEmpty())meta.lore(lore);meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);if(target!=null)meta.getPersistentDataContainer().set(targetKey,PersistentDataType.STRING,target.toString());item.setItemMeta(meta);return item;}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;event.setCancelled(true);if(closed)return;if(!management.active(player,holder.registrar()).map(value->value.id().equals(holder.character())).orElse(false)||!management.activeSession(player,holder.registrar()).map(holder.session()::equals).orElse(false)){player.sendMessage("Party action refused: the character or session changed.");return;}if(holder.screen()==Screen.NAME&&event.getRawSlot()==2){String name=event.getView() instanceof org.bukkit.inventory.view.AnvilView anvil?anvil.getRenameText():null;if(name!=null)management.register(player,holder.registrar(),name,outcome->respond(player,holder.registrar(),outcome,"Registered party."));return;}var clicked=event.getCurrentItem();if(clicked==null)return;var data=clicked.getItemMeta().getPersistentDataContainer();String action=data.get(actionKey,PersistentDataType.STRING);UUID target=uuid(data.get(targetKey,PersistentDataType.STRING));if("register".equals(action)){open(player,holder.registrar(),Screen.NAME,0);return;}if("invitations".equals(action)){open(player,holder.registrar(),Screen.INVITATIONS,0);return;}if("invite".equals(action)){open(player,holder.registrar(),Screen.INVITE,0);return;}if("outgoing".equals(action)){open(player,holder.registrar(),Screen.OUTGOING,0);return;}if("members".equals(action)){open(player,holder.registrar(),Screen.MEMBERS,0);return;}if("previous".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()-1);return;}if("next".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()+1);return;}if("leave".equals(action)){management.leave(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Left party."));return;}if("disband".equals(action)){management.disband(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Party ended."));return;}if(target==null)return;if("invite-target".equals(action)){management.invite(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation saved."));return;}if("cancel-invitation".equals(action)){management.cancel(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation cancelled."));return;}if("invitation".equals(action)){if(event.isRightClick())management.decline(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation declined."));else management.accept(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Joined party."));return;}if("member".equals(action)){if(event.isRightClick())management.transfer(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Leadership transferred."));else management.kick(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Member removed."));}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
private void respond(Player player,UUID registrar,PartyManagement.Outcome outcome,String success){if(!player.isOnline())return;if(outcome.status()==PartyManagement.Status.APPLIED){player.sendMessage(success);open(player,registrar);}else player.sendMessage("Party action refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");}
private static UUID uuid(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
@Override public void close(){closed=true;}
}
@@ -1,131 +1,40 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.*;
import java.util.function.Predicate;
/** Server-thread-confined, player-owned parties. No state survives a plugin/server restart. */
/** Party facade: durable character identity in production, retained transient constructor for isolated legacy tests. */
public final class PartyRoster implements AutoCloseable {
public record Party(UUID id, UUID leader, List<UUID> members) {
public Party { members = List.copyOf(members); }
}
public record Invitation(UUID id, UUID party, UUID leader, UUID invitee, Instant expires) { }
private final Map<UUID, Party> membership = new HashMap<>();
private final Map<UUID, Invitation> invitations = new HashMap<>();
private final Set<UUID> departed = new HashSet<>();
private boolean closed;
private final Clock clock;
private final Predicate<UUID> online;
private final java.util.function.IntSupplier maximumSize;
public PartyRoster(Clock clock, Predicate<UUID> online) { this(clock, online, () -> 6); }
public PartyRoster(Clock clock, Predicate<UUID> online, java.util.function.IntSupplier maximumSize) {
this.clock = Objects.requireNonNull(clock);
this.online = Objects.requireNonNull(online);
this.maximumSize = Objects.requireNonNull(maximumSize);
maximumSize();
}
public int maximumSize() {
int value = maximumSize.getAsInt();
if (value < 1) throw new IllegalStateException("Party maximum must be positive");
return value;
}
public Optional<Party> create(UUID player) {
if (closed || !connected(player) || membership.containsKey(player)) return Optional.empty();
var party = new Party(UUID.randomUUID(), player, List.of(player));
membership.put(player, party);
return Optional.of(party);
}
public Optional<Party> partyOf(UUID player) { return Optional.ofNullable(membership.get(player)); }
public List<Invitation> invitationsFor(UUID player) {
expire();
return invitations.values().stream().filter(i -> i.invitee().equals(player))
.sorted(java.util.Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();
}
public Optional<Invitation> invite(UUID leader, UUID party, UUID invitee) {
expire();
var group = managedBy(leader, party);
if (group == null || group.members().size() >= maximumSize() || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
var existing = invitations.values().stream().filter(i -> i.party().equals(party) && i.invitee().equals(invitee)).findFirst();
if (existing.isPresent()) return existing;
var invitation = new Invitation(UUID.randomUUID(), party, leader, invitee, clock.instant().plusSeconds(60));
invitations.put(invitation.id(), invitation);
return Optional.of(invitation);
}
public boolean accept(UUID player, UUID invitation) {
expire();
var offer = invitations.get(invitation);
if (offer == null || !offer.invitee().equals(player) || !connected(player) || membership.containsKey(player)) return false;
var group = membership.get(offer.leader());
if (group == null || group.members().size() >= maximumSize() || !group.id().equals(offer.party())) return false;
var members = new ArrayList<>(group.members());
members.add(player);
var updated = new Party(group.id(), group.leader(), members);
for (var member : members) membership.put(member, updated);
invitations.values().removeIf(i -> i.invitee().equals(player));
return true;
}
public boolean kick(UUID leader, UUID party, UUID member) {
var group = managedBy(leader, party);
if (group == null || !group.members().contains(member) || member.equals(leader)) return false;
removeMember(group, member);
return true;
}
public boolean disband(UUID leader, UUID party) {
var group = managedBy(leader, party);
if (group == null) return false;
remove(group);
return true;
}
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;
}
public boolean leave(UUID player, UUID party) {
var group = membership.get(player);
if (group == null || !group.id().equals(party) || !connected(player)) return false;
if (group.leader().equals(player)) remove(group);
else removeMember(group, player);
return true;
}
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);
}
public void arrive(UUID player) { departed.remove(player); }
public void disconnect(UUID player) {
var group = membership.get(player);
if (group == null) return;
departed.add(player);
if (group.members().stream().noneMatch(this::connected)) remove(group);
}
private boolean connected(UUID player) { return !departed.contains(player) && online.test(player); }
private void expire() {
var now = clock.instant();
invitations.values().removeIf(i -> !now.isBefore(i.expires()));
}
private void remove(Party group) {
for (var member : group.members()) membership.remove(member);
departed.removeAll(group.members());
invitations.values().removeIf(i -> i.party().equals(group.id()));
}
@Override public void close() {
closed = true;
membership.clear();
invitations.clear();
departed.clear();
}
public record Party(UUID id,UUID leader,List<UUID> members,UUID leaderCharacter,List<UUID> characters){public Party{members=List.copyOf(members);characters=List.copyOf(characters);}public Party(UUID id,UUID leader,List<UUID> members){this(id,leader,members,leader,members);}}
public record Invitation(UUID id,UUID party,UUID leader,UUID invitee,Instant expires){}
public record RewardMembership(Object key,List<UUID> activePlayers){public RewardMembership{Objects.requireNonNull(key);activePlayers=List.copyOf(activePlayers);}}
private final Map<UUID,Party> membership=new HashMap<>();private final Map<UUID,Invitation> invitations=new HashMap<>();private final Set<UUID> departed=new HashSet<>();
private final Clock clock;private final Predicate<UUID> online;private final java.util.function.IntSupplier maximumSize;
private final CharacterPartyDirectory durable;private final EquipmentSessions sessions;private boolean closed;
public PartyRoster(Clock clock,Predicate<UUID> online){this(clock,online,()->6);}
public PartyRoster(Clock clock,Predicate<UUID> online,java.util.function.IntSupplier maximumSize){this.clock=Objects.requireNonNull(clock);this.online=Objects.requireNonNull(online);this.maximumSize=Objects.requireNonNull(maximumSize);durable=null;sessions=null;maximumSize();}
public PartyRoster(CharacterPartyDirectory durable,EquipmentSessions sessions,java.util.function.IntSupplier maximumSize){this.durable=Objects.requireNonNull(durable);this.sessions=Objects.requireNonNull(sessions);this.maximumSize=Objects.requireNonNull(maximumSize);clock=Clock.systemUTC();online=id->true;maximumSize();}
public int maximumSize(){int value=maximumSize.getAsInt();if(value<1)throw new IllegalStateException("Party maximum must be positive");return value;}
public Optional<Party> create(UUID player){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();var result=durable.create(character,"Party "+character.toString().substring(0,8)).join();return result.party().flatMap(this::project);}if(closed||!connected(player)||membership.containsKey(player))return Optional.empty();var party=new Party(UUID.randomUUID(),player,List.of(player));membership.put(player,party);return Optional.of(party);}
public Optional<Party> partyOf(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);return character==null?Optional.empty():durable.partyOf(character).flatMap(this::project);}return Optional.ofNullable(membership.get(player));}
public Optional<RewardMembership> rewardMembership(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();return durable.partyOf(character).map(party->new RewardMembership(party.id(),activePlayers(party)));}var party=membership.get(player);return party==null?Optional.empty():Optional.of(new RewardMembership(party.id(),party.members().stream().filter(this::connected).toList()));}
public List<Invitation> invitationsFor(UUID player){if(durable!=null){if(!durable.ready())return List.of();var character=character(player);if(character==null)return List.of();return durable.invitationsFor(character).stream().map(value->new Invitation(value.id(),value.party(),new UUID(0,0),player,Instant.MAX)).toList();}expire();return invitations.values().stream().filter(i->i.invitee().equals(player)).sorted(Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();}
public Optional<Invitation> invite(UUID leader,UUID party,UUID invitee){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var source=character(leader);var target=character(invitee);if(source==null||target==null)return Optional.empty();var result=durable.invite(source,party,target).join();return result.invitation().map(value->new Invitation(value.id(),value.party(),leader,invitee,Instant.MAX));}expire();var group=managedBy(leader,party);if(group==null||group.members().size()>=maximumSize()||!connected(invitee)||membership.containsKey(invitee))return Optional.empty();var existing=invitations.values().stream().filter(i->i.party().equals(party)&&i.invitee().equals(invitee)).findFirst();if(existing.isPresent())return existing;var invitation=new Invitation(UUID.randomUUID(),party,leader,invitee,clock.instant().plusSeconds(60));invitations.put(invitation.id(),invitation);return Optional.of(invitation);}
public boolean accept(UUID player,UUID invitation){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var character=character(player);return character!=null&&durable.accept(character,invitation).join().status()==CharacterPartyDirectory.Status.APPLIED;}expire();var offer=invitations.get(invitation);if(offer==null||!offer.invitee().equals(player)||!connected(player)||membership.containsKey(player))return false;var group=membership.get(offer.leader());if(group==null||group.members().size()>=maximumSize()||!group.id().equals(offer.party()))return false;var members=new ArrayList<>(group.members());members.add(player);var updated=new Party(group.id(),group.leader(),members);for(var member:members)membership.put(member,updated);invitations.values().removeIf(i->i.invitee().equals(player));return true;}
public boolean kick(UUID leader,UUID party,UUID member){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var a=character(leader);var b=character(member);return a!=null&&b!=null&&durable.kick(a,party,b).join().status()==CharacterPartyDirectory.Status.APPLIED;}var group=managedBy(leader,party);if(group==null||!group.members().contains(member)||member.equals(leader))return false;removeMember(group,member);return true;}
public boolean disband(UUID leader,UUID party){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var character=character(leader);return character!=null&&durable.disband(character,party).join().status()==CharacterPartyDirectory.Status.APPLIED;}var group=managedBy(leader,party);if(group==null)return false;remove(group);return true;}
public boolean leave(UUID player,UUID party){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var character=character(player);return character!=null&&durable.leave(character,party).join().status()==CharacterPartyDirectory.Status.APPLIED;}var group=membership.get(player);if(group==null||!group.id().equals(party)||!connected(player))return false;if(group.leader().equals(player))remove(group);else removeMember(group,player);return true;}
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);}
public void arrive(UUID player){if(durable==null)departed.remove(player);}public void disconnect(UUID player){if(durable!=null)return;var group=membership.get(player);if(group==null)return;departed.add(player);if(group.members().stream().noneMatch(this::connected))remove(group);}
private boolean connected(UUID player){return!departed.contains(player)&&online.test(player);}private void expire(){var now=clock.instant();invitations.values().removeIf(i->!now.isBefore(i.expires()));}private void remove(Party group){for(var member:group.members())membership.remove(member);departed.removeAll(group.members());invitations.values().removeIf(i->i.party().equals(group.id()));}
@Override public void close(){closed=true;membership.clear();invitations.clear();departed.clear();}
}
@@ -1,6 +1,8 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.presentation.CombatFeedback;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
@@ -53,6 +55,7 @@ public final class HealthSteal implements Listener, AutoCloseable {
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private final Consumer<Failure> failures;
private final CombatFeedback feedback;
private final CombatSources sources;
private final Map<UUID, Candidate> lethal = new java.util.HashMap<>();
private final Set<PlayerDeathEvent> fantasyDefeats = Collections.newSetFromMap(new IdentityHashMap<>());
@@ -65,10 +68,16 @@ public final class HealthSteal implements Listener, AutoCloseable {
public HealthSteal(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
this(settings, characters, sessions, nextTick, healingEvents, nativeLimit, failures, null);
}
public HealthSteal(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures, CombatFeedback feedback) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
this.failures = Objects.requireNonNull(failures); sources = new CombatSources(sessions);
this.failures = Objects.requireNonNull(failures); this.feedback = feedback; sources = new CombatSources(sessions);
}
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
@@ -187,7 +196,11 @@ public final class HealthSteal implements Listener, AutoCloseable {
if (attribute == null) return;
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
double publish = Math.min(maximum, record.health().doubleValue());
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) player.setHealth(publish);
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) {
player.setHealth(publish);
if (feedback != null) feedback.report("Health Steal", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
} finally { finish(character); }
}
@@ -0,0 +1,29 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.bukkit.potion.PotionEffectType;
/** Current-session native Night Vision I layer for the learned character passive. */
public final class LowLightVision implements AutoCloseable {
public static final String ID = "low_light_vision";
private final PersistentPotionEffect effect;
public LowLightVision(EquipmentSessions sessions) {
effect = new PersistentPotionEffect(ID, PotionEffectType.NIGHT_VISION, 0, false, sessions);
}
public PassiveDefinition definition() {
return new PassiveDefinition(ID, "Low-Light Vision", Map.of(Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
List.of("Continuously provides native Night Vision I while this character is active.",
"Preserves unrelated Night Vision and does not change light, darkness or mob behavior."));
}
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
public void sweep() { effect.sweep(); }
@Override public void close() { effect.close(); }
}
@@ -0,0 +1,145 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Bounded current-character ownership for one persistent native potion layer. */
final class PersistentPotionEffect implements AutoCloseable {
private static final int MAX_ACTIVE_CHECKS = 256;
private final class State {
final EquipmentSessions.Active active;
final Player player;
PotionEffect owned;
PotionEffect displaced;
long displacedUntil;
long refreshedAt;
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
void refresh() {
refreshedAt = sweepGeneration;
if (displaced != null && !displaced.isInfinite() && sweepGeneration >= displacedUntil) displaced = null;
PotionEffect current;
try { current = player.getPotionEffect(type); }
catch (RuntimeException unavailable) { return; }
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
if (!eligible()) { remove(); return; }
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
try {
if (player.addPotionEffect(effect, true)) {
owned = effect; displaced = current;
displacedUntil = current == null ? 0 : current.isInfinite()
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
}
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
}
boolean eligible() {
if (closed || !sessions.current(active)) return false;
var record = sessions.currentRecord(active).orElse(null);
return record != null && !record.dead() && record.passives().contains(passive);
}
void remove() {
if (owned != null) {
PotionEffect current;
try { current = player.getPotionEffect(type); }
catch (RuntimeException unavailable) { return; }
if (same(current, owned)) {
try {
player.removePotionEffect(type);
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
}
} catch (RuntimeException unavailable) { return; }
}
owned = null; displaced = null;
}
states.remove(active.player(), this);
}
}
private final String passive;
private final PotionEffectType type;
private final int amplifier;
private final boolean replaceWeaker;
private final EquipmentSessions sessions;
private final Map<UUID, State> states = new java.util.HashMap<>();
private int activeCursor;
private int stateCursor;
private long sweepGeneration;
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
PersistentPotionEffect(String passive, PotionEffectType type, int amplifier, boolean replaceWeaker,
EquipmentSessions sessions) {
this.passive = Objects.requireNonNull(passive); this.type = Objects.requireNonNull(type);
if (passive.isBlank() || amplifier < 0) throw new IllegalArgumentException("Invalid persistent potion effect");
this.amplifier = amplifier; this.replaceWeaker = replaceWeaker; this.sessions = Objects.requireNonNull(sessions);
}
void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
if (closed || sweepTask != null) throw new IllegalStateException("Passive potion sweep already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
void sweep() {
if (closed) return;
sweepGeneration++;
var active = sessions.activeCharacters();
int activeChecks = Math.min(MAX_ACTIVE_CHECKS, active.size());
for (int index = 0; index < activeChecks; index++) {
var snapshot = active.get((activeCursor + index) % active.size());
reconcile(snapshot);
}
activeCursor = active.isEmpty() ? 0 : (activeCursor + activeChecks) % active.size();
var known = List.copyOf(states.values());
int stateChecks = Math.min(MAX_ACTIVE_CHECKS, known.size());
for (int index = 0; index < stateChecks; index++) {
var state = known.get((stateCursor + index) % known.size());
if (state.refreshedAt != sweepGeneration) state.refresh();
}
stateCursor = known.isEmpty() ? 0 : (stateCursor + stateChecks) % known.size();
}
private void reconcile(EquipmentSessions.ActiveCharacter snapshot) {
var active = snapshot.session();
var state = states.get(active.player());
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(passive);
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
if (state == null) {
var player = sessions.currentPlayer(active).orElse(null);
if (player == null) return;
state = new State(active, player); states.put(active.player(), state);
}
state.refresh();
}
private static boolean same(PotionEffect first, PotionEffect second) {
return first != null && second != null && first.getType().equals(second.getType())
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite()
&& first.isAmbient() == second.isAmbient() && first.hasParticles() == second.hasParticles()
&& first.hasIcon() == second.hasIcon();
}
@Override public void close() {
if (closed) return;
closed = true;
if (sweepTask != null) sweepTask.cancel();
for (var state : List.copyOf(states.values())) state.remove();
states.clear();
}
}
@@ -5,145 +5,25 @@ import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Current-session Speed I layer for the learned character passive. */
/** Current-session native Speed I layer for the learned character passive. */
public final class SpeedI implements AutoCloseable {
public static final String ID = "speed_i";
private static final int MAX_ACTIVE_CHECKS = 256;
private static final int AMPLIFIER = 0;
private final PersistentPotionEffect effect;
private final class State {
final EquipmentSessions.Active active;
final Player player;
PotionEffect owned;
PotionEffect displaced;
long displacedUntil;
long refreshedAt;
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
void refresh() {
refreshedAt = sweepGeneration;
if (displaced != null && !displaced.isInfinite() && sweepGeneration >= displacedUntil) displaced = null;
PotionEffect current;
try { current = player.getPotionEffect(PotionEffectType.SPEED); }
catch (RuntimeException unavailable) { return; }
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
if (!eligible()) { remove(); return; }
if (current != null && current.getAmplifier() >= AMPLIFIER) return;
var effect = new PotionEffect(PotionEffectType.SPEED, PotionEffect.INFINITE_DURATION,
AMPLIFIER, false, false, true);
try {
if (player.addPotionEffect(effect, true)) {
owned = effect; displaced = current;
displacedUntil = current == null ? 0 : current.isInfinite()
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
}
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
}
boolean eligible() {
if (closed || !sessions.current(active)) return false;
var record = sessions.currentRecord(active).orElse(null);
return record != null && !record.dead() && record.passives().contains(ID);
}
void remove() {
if (owned != null) {
PotionEffect current;
try { current = player.getPotionEffect(PotionEffectType.SPEED); }
catch (RuntimeException unavailable) { return; }
if (same(current, owned)) {
try {
player.removePotionEffect(PotionEffectType.SPEED);
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
}
} catch (RuntimeException unavailable) { return; }
}
owned = null; displaced = null;
}
states.remove(active.player(), this);
}
public SpeedI(EquipmentSessions sessions) {
effect = new PersistentPotionEffect(ID, PotionEffectType.SPEED, 0, true, sessions);
}
private final EquipmentSessions sessions;
private final Map<UUID, State> states = new java.util.HashMap<>();
private int activeCursor;
private int stateCursor;
private long sweepGeneration;
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
public SpeedI(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
public PassiveDefinition definition() {
return new PassiveDefinition(ID, "Speed I", Map.of(Stat.DEX, BigInteger.valueOf(14)), Set.of(), BigInteger.ONE,
List.of("Continuously provides the standard native Speed I effect while this character is active.",
"Does not stack with equal Speed I and preserves stronger unrelated Speed effects."));
}
public void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
if (closed || sweepTask != null) throw new IllegalStateException("Speed I sweep already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
/** Bounded rotating reconciliation for active sessions and previously owned layers. */
public void sweep() {
if (closed) return;
sweepGeneration++;
var active = sessions.activeCharacters();
int activeChecks = Math.min(MAX_ACTIVE_CHECKS, active.size());
for (int index = 0; index < activeChecks; index++) {
var snapshot = active.get((activeCursor + index) % active.size());
reconcile(snapshot);
}
activeCursor = active.isEmpty() ? 0 : (activeCursor + activeChecks) % active.size();
var known = List.copyOf(states.values());
int stateChecks = Math.min(MAX_ACTIVE_CHECKS, known.size());
for (int index = 0; index < stateChecks; index++) {
var state = known.get((stateCursor + index) % known.size());
if (state.refreshedAt != sweepGeneration) state.refresh();
}
stateCursor = known.isEmpty() ? 0 : (stateCursor + stateChecks) % known.size();
}
private void reconcile(EquipmentSessions.ActiveCharacter snapshot) {
var active = snapshot.session();
var state = states.get(active.player());
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(ID);
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
if (state == null) {
var player = sessions.currentPlayer(active).orElse(null);
if (player == null) return;
state = new State(active, player); states.put(active.player(), state);
}
state.refresh();
}
private static boolean same(PotionEffect first, PotionEffect second) {
return first != null && second != null && first.getType().equals(second.getType())
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite()
&& first.isAmbient() == second.isAmbient() && first.hasParticles() == second.hasParticles()
&& first.hasIcon() == second.hasIcon();
}
@Override public void close() {
if (closed) return;
closed = true;
if (sweepTask != null) sweepTask.cancel();
for (var state : List.copyOf(states.values())) state.remove();
states.clear();
}
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
public void sweep() { effect.sweep(); }
@Override public void close() { effect.close(); }
}
@@ -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);}
}
@@ -0,0 +1,11 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.shop.ArmorCatalog;
import java.io.IOException;
import java.util.Optional;
/** Durable adapter boundary for the one shared armor catalog. */
public interface ArmorCatalogRepository {
Optional<ArmorCatalog> load() throws IOException;
void save(ArmorCatalog catalog) throws IOException;
}
@@ -0,0 +1,84 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.shop.ArmorProduct;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
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 recovery plans spanning one catalog reservation and character gear purchase. */
public final class ArmorPurchaseOperationStore implements AutoCloseable {
public record Plan(UUID id, UUID character, BigInteger expectedCharacterRevision, UUID catalogRevision,
ArmorProduct product, BigInteger price) {
public Plan {
Objects.requireNonNull(id); Objects.requireNonNull(character); Objects.requireNonNull(expectedCharacterRevision);
Objects.requireNonNull(catalogRevision); Objects.requireNonNull(product); Objects.requireNonNull(price);
if(expectedCharacterRevision.signum()<0||price.signum()<=0)throw new IllegalArgumentException("Invalid armor purchase plan");
}
}
private final Path directory;
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armor-operation-persistence"));
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained=new CompletableFuture<>();
public ArmorPurchaseOperationStore(Path data){directory=data.toAbsolutePath().resolve("armor-purchase-operations");}
public CompletionStage<Plan> prepare(Plan plan){return submit(()->{
Files.createDirectories(directory);Path target=file(plan.id());
if(Files.exists(target)) {
var existing=decode(Files.readString(target,StandardCharsets.UTF_8));
if(!existing.equals(plan))throw new IOException("Armor operation identity collision");
return existing;
}
write(target,encode(plan));return plan;
});}
public CompletionStage<Void> complete(UUID id){return submit(()->{if(Files.deleteIfExists(file(id)))DurableFiles.forceDirectory(directory);return null;});}
public CompletionStage<List<Plan>> load(){return submit(this::loadBlocking);}
private List<Plan> loadBlocking()throws IOException {
if(Files.notExists(directory))return List.of();
var result=new ArrayList<Plan>();
try(var files=Files.list(directory)) {
for(var path:files.filter(value->value.getFileName().toString().endsWith(".yml")).sorted().toList()) {
if(Files.size(path)>65_536)throw new IOException("Armor operation too large");
result.add(decode(Files.readString(path,StandardCharsets.UTF_8)));
}
}
return List.copyOf(result);
}
private Path file(UUID id){return directory.resolve(id+".yml");}
private static String encode(Plan plan){Map<String,Object> row=new LinkedHashMap<>();row.put("schema-version",1);
row.put("operation-id",plan.id().toString());row.put("character",plan.character().toString());
row.put("expected-character-revision",plan.expectedCharacterRevision().toString());
row.put("catalog-revision",plan.catalogRevision().toString());row.put("slot",plan.product().slot().name());
row.put("tier",plan.product().tier().name());row.put("price",plan.price().toString());return yaml().dump(row);}
private static Plan decode(String input){Object decoded=yaml().load(input);if(!(decoded instanceof Map<?,?> row)||
!"1".equals(String.valueOf(row.get("schema-version"))))throw new IllegalArgumentException("Invalid armor operation schema");
return new Plan(UUID.fromString(text(row,"operation-id")),UUID.fromString(text(row,"character")),
new BigInteger(text(row,"expected-character-revision")),UUID.fromString(text(row,"catalog-revision")),
new ArmorProduct(GearSlot.valueOf(text(row,"slot")),GearTier.valueOf(text(row,"tier"))),new BigInteger(text(row,"price")));}
private static void write(Path target,String content)throws IOException {Path temporary=Files.createTempFile(target.getParent(),"armor-operation-",".tmp");
try{byte[] bytes=content.getBytes(StandardCharsets.UTF_8);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){
var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}
try{Files.move(temporary,target,StandardCopyOption.ATOMIC_MOVE);}catch(AtomicMoveNotSupportedException unsupported){
throw new IOException("Atomic armor-operation persistence unavailable",unsupported);}DurableFiles.forceDirectory(target.getParent());}
finally{Files.deleteIfExists(temporary);}}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);
options.setCodePointLimit(65_536);return new Yaml(new SafeConstructor(options));}
private static String text(Map<?,?> row,String key){Object value=row.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(
new IOException("Armor operation store closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}
catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
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,11 @@
package games.dmg.fantasystick.persistence;
import java.util.Objects;
/** Permanent helmet-free character armor state. */
public record CharacterGear(GearTier chestplate,GearTier leggings,GearTier boots) {
public CharacterGear{if(chestplate==null||leggings==null||boots==null)throw new IllegalArgumentException("Missing gear tier");}
public static CharacterGear none(){return new CharacterGear(GearTier.NONE,GearTier.NONE,GearTier.NONE);}
public GearTier tier(GearSlot slot){return switch(slot){case CHESTPLATE->chestplate;case LEGGINGS->leggings;case BOOTS->boots;};}
public CharacterGear with(GearSlot slot,GearTier tier){Objects.requireNonNull(slot);Objects.requireNonNull(tier);return switch(slot){case CHESTPLATE->new CharacterGear(tier,leggings,boots);case LEGGINGS->new CharacterGear(chestplate,tier,boots);case BOOTS->new CharacterGear(chestplate,leggings,tier);};}
}
@@ -18,13 +18,23 @@ public record CharacterRecord(
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt) {
boolean dead, UUID deathId, Instant diedAt,
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,CharacterGear gear) {
public CharacterRecord(UUID id,String name,BigInteger revision,Map<Stat,BigInteger> stats,int level,BigInteger experience,BigInteger unspentPoints,BigDecimal health,Set<String> learned,List<String> loadout,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,CharacterGear.none());}
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills, boolean dead, UUID deathId,
Instant diedAt, BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout, passives,
cooldowns, restrictedKills, dead, deathId, diedAt, coins, coinsInitialized, rewardReceipts, BigInteger.ZERO);
}
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
boolean dead, UUID deathId, Instant diedAt) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
List.of("", "", ""), cooldowns, Map.of(), dead, deathId, diedAt);
List.of("", "", ""), cooldowns, Map.of(), dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
}
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
@@ -33,7 +43,16 @@ public record CharacterRecord(
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
List.of("", "", ""), cooldowns, restrictedKills, dead, deathId, diedAt);
List.of("", "", ""), cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
}
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout, passives,
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
}
public CharacterRecord {
@@ -43,12 +62,16 @@ public record CharacterRecord(
Objects.requireNonNull(experience);
Objects.requireNonNull(unspentPoints);
Objects.requireNonNull(health);
Objects.requireNonNull(coins);
Objects.requireNonNull(pvpCoinEscrow);
Objects.requireNonNull(gear);
stats = Map.copyOf(stats);
learned = Set.copyOf(learned);
loadout = List.copyOf(loadout);
passives = List.copyOf(passives);
cooldowns = Map.copyOf(cooldowns);
restrictedKills = Map.copyOf(restrictedKills);
rewardReceipts = Set.copyOf(rewardReceipts);
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
@@ -62,6 +85,8 @@ public record CharacterRecord(
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|| restrictedKills.size() > 1024
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || !coinsInitialized && coins.signum() != 0
|| rewardReceipts.size() > 4096 || !dead && pvpCoinEscrow.signum() != 0
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
throw new IllegalArgumentException("Invalid character record");
}
@@ -24,12 +24,23 @@ import java.util.concurrent.RejectedExecutionException;
public final class CharacterStorage implements AutoCloseable {
private final YamlCharacterRepository repository;
private final Clock clock;
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
private final java.util.random.RandomGenerator coinRandom;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-character-persistence"));
public CharacterStorage(YamlCharacterRepository repository, Clock clock) {
this(repository, clock, games.dmg.fantasystick.progression.CoinRules::defaults,
java.util.random.RandomGenerator.getDefault());
}
public CharacterStorage(YamlCharacterRepository repository, Clock clock,
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
java.util.random.RandomGenerator coinRandom) {
this.repository = Objects.requireNonNull(repository);
this.clock = Objects.requireNonNull(clock);
this.coinRules = Objects.requireNonNull(coinRules);
this.coinRandom = Objects.requireNonNull(coinRandom);
}
public CompletableFuture<Void> create(CharacterRecord character) {
@@ -54,7 +65,7 @@ public final class CharacterStorage implements AutoCloseable {
|| record.experience().signum() != 0 || record.stats().values().stream().anyMatch(value -> value.signum() != 0)
|| record.health().compareTo(java.math.BigDecimal.TEN) != 0
|| !record.learned().isEmpty() || record.passives().stream().anyMatch(id -> !id.isEmpty())
|| !record.cooldowns().isEmpty()
|| !record.cooldowns().isEmpty() || !record.coinsInitialized() || !record.rewardReceipts().isEmpty() || !record.gear().equals(CharacterGear.none())
|| record.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
throw new IllegalArgumentException("Craft batch requires distinct new characters");
}
@@ -193,7 +204,20 @@ public final class CharacterStorage implements AutoCloseable {
}
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
return submit(() -> repository.load(id));
return submit(() -> load(id));
}
/** Persist a legacy character's one-time configured migration roll before exposing it. */
private Optional<CharacterRecord> load(UUID id) throws IOException {
var found = repository.load(id);
if (found.isEmpty() || found.orElseThrow().coinsInitialized()) return found;
var old = found.orElseThrow();
var migrated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(),
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(migrated, old.revision());
return Optional.of(migrated);
}
/** Persist an accepted native rename against the latest live record; never create a character from item metadata. */
@@ -207,7 +231,7 @@ public final class CharacterStorage implements AutoCloseable {
if (old.name().equals(name)) return Optional.of(old);
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
});
@@ -247,29 +271,90 @@ public final class CharacterStorage implements AutoCloseable {
int gained = Math.max(0, level - old.level());
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
});
}
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
public enum CoinSpendStatus { APPLIED, DUPLICATE, INSUFFICIENT, STALE, UNAVAILABLE }
public record CoinSpend(CoinSpendStatus status, Optional<CharacterRecord> record) { }
/** Persist one operation-keyed debit; an acknowledged operation can never charge twice. */
public CompletableFuture<CoinSpend> spendCoins(UUID characterId, BigInteger expectedRevision, UUID operationId,
BigInteger amount) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(operationId); Objects.requireNonNull(amount);
if (amount.signum() < 0) throw new IllegalArgumentException("Coin debit must not be negative");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new CoinSpend(CoinSpendStatus.UNAVAILABLE, Optional.empty());
}
var old = found.orElseThrow();
if (old.rewardReceipts().contains(operationId)) {
return new CoinSpend(CoinSpendStatus.DUPLICATE, Optional.of(old));
}
if (!old.revision().equals(expectedRevision)) {
return new CoinSpend(CoinSpendStatus.STALE, Optional.of(old));
}
if (old.coins().compareTo(amount) < 0) {
return new CoinSpend(CoinSpendStatus.INSUFFICIENT, Optional.of(old));
}
var receipts = new java.util.HashSet<>(old.rewardReceipts());
if (receipts.size() >= 4096) receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());
receipts.add(operationId);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(),
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new CoinSpend(CoinSpendStatus.APPLIED, Optional.of(saved));
});
}
public enum CoinRefundStatus { APPLIED, DUPLICATE, UNAVAILABLE }
public record CoinRefund(CoinRefundStatus status,Optional<CharacterRecord> record){}
/** Compensate a persisted debit exactly once while retaining both audit receipts. */
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
public enum GearPurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,NOT_UPGRADE,STALE,UNAVAILABLE }
public record GearPurchase(GearPurchaseStatus status,Optional<CharacterRecord> record){}
/** One character-file transaction owns both the exact coin debit and strict gear replacement. */
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target));repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, DUPLICATE, STALE, UNAVAILABLE }
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
/** Persist one combat share and its restricted-kill reference in the same character revision. */
/** Compatibility route for callers that do not yet carry a stable reward identity or coins. */
public CompletableFuture<CombatAward> awardCombatExperience(UUID characterId, BigInteger expectedRevision,
BigInteger reward, ProgressionRules progression,
games.dmg.fantasystick.progression.CombatRewardRules rewards,
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(reward); Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
return awardCombatReward(characterId, expectedRevision, UUID.randomUUID(), reward, BigInteger.ZERO,
progression, rewards, restrictedKill);
}
/** Persist one idempotent XP/coin recipient share, receipt and spacing reference in one revision. */
public CompletableFuture<CombatAward> awardCombatReward(UUID characterId, BigInteger expectedRevision, UUID rewardId,
BigInteger experienceReward, BigInteger coinReward, ProgressionRules progression,
games.dmg.fantasystick.progression.CombatRewardRules rewards,
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(rewardId);
Objects.requireNonNull(experienceReward); Objects.requireNonNull(coinReward);
Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
if (experienceReward.signum() < 0 || coinReward.signum() < 0) {
throw new IllegalArgumentException("Combat rewards must not be negative");
}
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new CombatAward(CombatAwardStatus.UNAVAILABLE, Optional.empty(), 0);
}
var old = found.orElseThrow();
if (old.rewardReceipts().contains(rewardId)) {
return new CombatAward(CombatAwardStatus.DUPLICATE, Optional.of(old), 0);
}
if (!old.revision().equals(expectedRevision)) {
return new CombatAward(CombatAwardStatus.STALE, Optional.of(old), 0);
}
@@ -279,17 +364,19 @@ public final class CharacterStorage implements AutoCloseable {
if (restrictedKill != null) history.put(restrictedKill.world(), restrictedKill);
var cap = progression.xpPerLevel().multiply(BigInteger.valueOf(progression.maximumLevel() - 1L));
boolean atCap = old.level() >= progression.maximumLevel() || old.experience().compareTo(cap) >= 0;
var experience = eligible && !atCap ? old.experience().add(reward).min(cap) : old.experience();
var experience = eligible && !atCap ? old.experience().add(experienceReward).min(cap) : old.experience();
int level = atCap ? old.level() : experience.divide(progression.xpPerLevel()).intValueExact() + 1;
int gained = Math.max(0, level - old.level());
var status = !eligible ? CombatAwardStatus.SPACING_BLOCKED : atCap ? CombatAwardStatus.AT_CAP
: experience.equals(old.experience()) ? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
if (history.equals(old.restrictedKills()) && experience.equals(old.experience())) {
return new CombatAward(status, Optional.of(old), 0);
}
: experience.equals(old.experience()) && coinReward.signum() == 0
? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
var receipts = new java.util.HashSet<>(old.rewardReceipts());
if (receipts.size() >= 4096) receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());
receipts.add(rewardId);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt(),
old.coins().add(coinReward), true, receipts,old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new CombatAward(status, Optional.of(saved), gained);
});
@@ -329,7 +416,7 @@ public final class CharacterStorage implements AutoCloseable {
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
});
@@ -357,7 +444,7 @@ public final class CharacterStorage implements AutoCloseable {
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).toList();
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
});
@@ -392,7 +479,7 @@ public final class CharacterStorage implements AutoCloseable {
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -412,7 +499,7 @@ public final class CharacterStorage implements AutoCloseable {
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -435,7 +522,7 @@ public final class CharacterStorage implements AutoCloseable {
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -459,7 +546,7 @@ public final class CharacterStorage implements AutoCloseable {
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), cooldowns,
old.restrictedKills(), false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -504,7 +591,7 @@ public final class CharacterStorage implements AutoCloseable {
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
currentHealth.add(gain), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
old.deathId(), old.diedAt());
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
});
@@ -530,7 +617,7 @@ public final class CharacterStorage implements AutoCloseable {
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(saved, old.revision());
return Optional.of(saved);
});
@@ -543,14 +630,18 @@ public final class CharacterStorage implements AutoCloseable {
* Callers must retain their one-use session authority until the result resolves.
*/
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId) {
return death(characterId, null);
return death(characterId, null, false);
}
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, BigInteger expectedRevision) {
return death(characterId, Objects.requireNonNull(expectedRevision));
return death(characterId, Objects.requireNonNull(expectedRevision), false);
}
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision) {
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, boolean loseCoins) {
return death(characterId, null, loseCoins);
}
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision, boolean loseCoins) {
Objects.requireNonNull(characterId);
Instant diedAt = clock.instant();
UUID deathId = UUID.randomUUID();
@@ -561,7 +652,9 @@ public final class CharacterStorage implements AutoCloseable {
if (old.dead() || expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt,
loseCoins ? BigInteger.ZERO : old.coins(), old.coinsInitialized(), old.rewardReceipts(),
loseCoins ? old.coins() : BigInteger.ZERO,old.gear());
repository.replace(dead, old.revision());
return Optional.of(dead);
});
@@ -598,7 +691,7 @@ public final class CharacterStorage implements AutoCloseable {
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear());
repository.replace(restored, old.revision());
return Optional.of(restored);
});
@@ -54,6 +54,11 @@ final class CharacterYaml {
"y", position.y().toString(), "z", position.z().toString()));
});
root.put("restricted-kills", restrictedKills);
root.put("coins-initialized", record.coinsInitialized());
root.put("coins", record.coins().toString());
root.put("reward-receipts", new ArrayList<>(record.rewardReceipts().stream().sorted().map(UUID::toString).toList()));
root.put("pvp-coin-escrow", record.pvpCoinEscrow().toString());
root.put("gear",Map.of("chestplate",record.gear().chestplate().name(),"leggings",record.gear().leggings().name(),"boots",record.gear().boots().name()));
root.put("dead", record.dead());
if (record.deathId() != null) {
root.put("death-id", record.deathId().toString());
@@ -97,9 +102,17 @@ final class CharacterYaml {
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")),
root.containsKey("learned-passives") ? strings(root.get("learned-passives")) : List.of("", "", ""),
cooldowns, restrictedKills, dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
root.containsKey("died-at") ? instant(root.get("died-at")) : null,
root.containsKey("coins") ? number(root.get("coins")) : BigInteger.ZERO,
root.containsKey("coins-initialized") && bool(root.get("coins-initialized")),
root.containsKey("reward-receipts") ? strings(root.get("reward-receipts")).stream()
.map(UUID::fromString).collect(java.util.stream.Collectors.toUnmodifiableSet()) : java.util.Set.of(),
root.containsKey("pvp-coin-escrow") ? number(root.get("pvp-coin-escrow")) : BigInteger.ZERO,
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none());
}
private static CharacterGear gear(Object value){var gear=object(value);return new CharacterGear(GearTier.valueOf(text(gear.get("chestplate"))),GearTier.valueOf(text(gear.get("leggings"))),GearTier.valueOf(text(gear.get("boots"))));}
private static Map<?, ?> object(Object value) {
if (value instanceof Map<?, ?> map) return map;
throw new IllegalArgumentException("Expected YAML mapping");
@@ -114,6 +127,11 @@ final class CharacterYaml {
return list(value).stream().map(CharacterYaml::text).toList();
}
private static boolean bool(Object value) {
if (value instanceof Boolean result) return result;
throw new IllegalArgumentException("Expected YAML boolean");
}
private static String text(Object value) {
if (value instanceof String string) return string;
throw new IllegalArgumentException("Expected YAML string");
@@ -0,0 +1,67 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.progression.CoinRules;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
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 inclusive coin ranges. */
public final class CoinSettings implements AutoCloseable {
public static final String START_MIN="starting-coins-min", START_MAX="starting-coins-max";
public static final String HOSTILE_MIN="hostile-coin-drop-min", HOSTILE_MAX="hostile-coin-drop-max";
public static final String BOSS_MIN="boss-coin-drop-min", BOSS_MAX="boss-coin-drop-max";
private static final List<String> KEYS=List.of(START_MIN,START_MAX,HOSTILE_MIN,HOSTILE_MAX,BOSS_MIN,BOSS_MAX);
private static final Map<String,String> DEFAULTS=Map.of(START_MIN,"1",START_MAX,"6",HOSTILE_MIN,"1",HOSTILE_MAX,"5",BOSS_MIN,"25",BOSS_MAX,"50");
public record Update(String key,String value,CoinRules rules,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-coin-configuration"));
private volatile Map<String,String> effective;
private Map<String,String> persisted;
private long savedSequence,publishedSequence;
private boolean closed;
public CoinSettings(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.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 CoinRules rules(){return parse(effective);}
public static CoinRules parse(Map<String,String> values){
try{return new CoinRules(whole(values.get(START_MIN)),whole(values.get(START_MAX)),whole(values.get(HOSTILE_MIN)),whole(values.get(HOSTILE_MAX)),whole(values.get(BOSS_MIN)),whole(values.get(BOSS_MAX)));}
catch(RuntimeException invalid){throw new IllegalArgumentException("Coin settings must be ordered whole numbers from 0 through 2147483647",invalid);}
}
private static long whole(String value){var number=new java.math.BigInteger(value);if(number.signum()<0||number.compareTo(java.math.BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return number.longValueExact();}
public CompletionStage<Update> set(String key,String raw){
if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown coin setting");
String accepted;try{accepted=new java.math.BigInteger(raw).toString();}catch(RuntimeException invalid){throw new IllegalArgumentException("Coin setting must be a whole number",invalid);}
var preview=new LinkedHashMap<>(effective);preview.put(key,accepted);parse(preview);
synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));}
return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);var rules=parse(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,rules,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}},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,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);}finally{Files.deleteIfExists(temporary);}
}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}
}
@Override public synchronized void close(){closed=true;worker.shutdown();}
}
@@ -0,0 +1,14 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
/** Filesystem durability boundary used after atomically replacing or deleting acknowledged state. */
final class DurableFiles {
private DurableFiles() { }
static void forceDirectory(Path directory) throws IOException {
try (var channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
}
}
@@ -0,0 +1,171 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Fireball;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Fireball settings; publication remains a server-thread caller decision. */
public final class FireballSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "fireball-base-damage";
public static final String COOLDOWN = "fireball-cooldown-seconds";
public static final String RANGE = "fireball-range-blocks";
public static final String LIFETIME = "fireball-lifetime-seconds";
public static final String SPEED = "fireball-projectile-speed-blocks-per-tick";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "6", COOLDOWN, "1.2", RANGE, "32",
LIFETIME, "5", SPEED, "3");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Fireball.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-fireball-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public FireballSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
FireballSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Fireball.Settings settings() { return parse(effectiveValues); }
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence(); return true;
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Fireball configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Fireball.Settings parse(Map<String, String> values) {
try {
return new Fireball.Settings(new java.math.BigInteger(values.get(BASE_DAMAGE)), seconds(values.get(COOLDOWN)),
finite(values.get(RANGE)), seconds(values.get(LIFETIME)), finite(values.get(SPEED)));
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Fireball settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
return Duration.ofNanos(new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact());
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, 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 savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
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"); }
}
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); }
}
@@ -36,7 +36,7 @@ public final class FrostShardSettings implements AutoCloseable {
public static final String SLOWNESS = "frost-shard-slowness-duration-seconds";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE, AMPLIFIER, SLOWNESS);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "5", RANGE, "32",
LIFETIME, "5", SPEED, "1", AMPLIFIER, "0", SLOWNESS, "2");
LIFETIME, "5", SPEED, "3", AMPLIFIER, "0", SLOWNESS, "2");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, FrostShard.Settings settings,
@@ -0,0 +1,186 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.shop.ArmorMarketRules;
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 bases, variance and tier stock bounds for future catalogs. */
public final class GearSettings implements AutoCloseable {
public static final String LEATHER="gear-price-leather", COPPER="gear-price-copper",
GOLD="gear-price-gold", IRON="gear-price-iron", DIAMOND="gear-price-diamond",
NETHERITE="gear-price-netherite", VARIANCE="gear-price-variance-percent";
private static final Map<GearTier,String> PRICE_KEYS=Map.of(
GearTier.LEATHER,LEATHER, GearTier.COPPER,COPPER, GearTier.GOLD,GOLD,
GearTier.IRON,IRON, GearTier.DIAMOND,DIAMOND, GearTier.NETHERITE,NETHERITE);
private static final Map<GearTier,String> TIER_NAMES=Map.of(
GearTier.LEATHER,"leather", GearTier.COPPER,"copper", GearTier.GOLD,"gold",
GearTier.IRON,"iron", GearTier.DIAMOND,"diamond", GearTier.NETHERITE,"netherite");
private static final Map<String,String> DEFAULTS=defaults();
private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
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-gear-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 GearSettings(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))));
validate(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 int price(GearTier tier){return wholeNonnegative(effective.get(priceKey(tier)));}
public ArmorMarketRules rules() {
var values=effective;
var prices=new EnumMap<GearTier,BigInteger>(GearTier.class);
var stock=new EnumMap<GearTier,ArmorMarketRules.StockRange>(GearTier.class);
for(var tier:PRICE_KEYS.keySet()) {
prices.put(tier,new BigInteger(values.get(priceKey(tier))));
stock.put(tier,new ArmorMarketRules.StockRange(
wholePositive(values.get(stockKey(tier,true))), wholePositive(values.get(stockKey(tier,false)))));
}
return new ArmorMarketRules(prices,wholeVariance(values.get(VARIANCE)),stock);
}
public CompletionStage<Update> set(String key,String raw) {
if(!KEYS.contains(key)) throw new IllegalArgumentException("Unknown gear market setting");
String accepted;
try { accepted=Integer.toString(parseForKey(key,raw)); }
catch(RuntimeException invalid) { throw new IllegalArgumentException("Invalid gear market setting",invalid); }
var preview=new LinkedHashMap<>(effective);
preview.put(key,accepted);
validate(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);
validate(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;
validate(update.values());
effective=update.values();
publishedSequence=update.sequence();
return true;
}
private static Map<String,String> defaults() {
var values=new LinkedHashMap<String,String>();
values.put(LEATHER,"2"); values.put(COPPER,"10"); values.put(GOLD,"25");
values.put(IRON,"75"); values.put(DIAMOND,"150"); values.put(NETHERITE,"500");
values.put(VARIANCE,"10");
int[][] ranges={{25,50},{20,40},{15,30},{10,20},{5,10},{1,3}};
GearTier[] tiers={GearTier.LEATHER,GearTier.COPPER,GearTier.GOLD,GearTier.IRON,GearTier.DIAMOND,GearTier.NETHERITE};
for(int i=0;i<tiers.length;i++) {
values.put(stockKey(tiers[i],true),Integer.toString(ranges[i][0]));
values.put(stockKey(tiers[i],false),Integer.toString(ranges[i][1]));
}
return Map.copyOf(values);
}
private static void validate(Map<String,String> values) {
for(var tier:PRICE_KEYS.keySet()) {
wholeNonnegative(values.get(priceKey(tier)));
int minimum=wholePositive(values.get(stockKey(tier,true)));
int maximum=wholePositive(values.get(stockKey(tier,false)));
if(minimum>maximum)throw new IllegalArgumentException("Stock minimum exceeds maximum");
}
wholeVariance(values.get(VARIANCE));
}
private static int parseForKey(String key,String value) {
if(key.equals(VARIANCE))return wholeVariance(value);
if(key.startsWith("gear-stock-"))return wholePositive(value);
return wholeNonnegative(value);
}
private static int wholeNonnegative(String value){return bounded(value,0,Integer.MAX_VALUE);}
private static int wholePositive(String value){return bounded(value,1,Integer.MAX_VALUE);}
private static int wholeVariance(String value){return bounded(value,0,100);}
private static int bounded(String value,int minimum,int maximum) {
var number=new BigInteger(Objects.requireNonNull(value));
if(number.compareTo(BigInteger.valueOf(minimum))<0||number.compareTo(BigInteger.valueOf(maximum))>0)
throw new IllegalArgumentException();
return number.intValueExact();
}
private static String priceKey(GearTier tier) {
var key=PRICE_KEYS.get(tier);
if(key==null)throw new IllegalArgumentException("None is not purchasable");
return key;
}
public static String stockKey(GearTier tier,boolean minimum) {
var name=TIER_NAMES.get(tier);
if(name==null)throw new IllegalArgumentException("None is not purchasable");
return "gear-stock-"+name+(minimum?"-min":"-max");
}
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);}
}
@@ -0,0 +1,4 @@
package games.dmg.fantasystick.persistence;
/** Purchasable character armor slots; head is intentionally absent and remains cosmetic-only. */
public enum GearSlot { CHESTPLATE, LEGGINGS, BOOTS }
@@ -0,0 +1,7 @@
package games.dmg.fantasystick.persistence;
/** Strict reviewed armor progression; enum order is authoritative for upgrade comparisons. */
public enum GearTier {
NONE, LEATHER, COPPER, GOLD, IRON, DIAMOND, NETHERITE;
public boolean higherThan(GearTier current){return ordinal()>current.ordinal();}
}
@@ -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); }
}
@@ -30,7 +30,7 @@ public final class OutOfCombatRecoverySettings implements AutoCloseable {
public static final String INTERVAL = "out-of-combat-healing-interval-seconds";
public static final String AMOUNT = "out-of-combat-healing-amount";
private static final List<String> KEYS = List.of(DELAY, INTERVAL, AMOUNT);
private static final Map<String, String> DEFAULTS = Map.of(DELAY, "30", INTERVAL, "10", AMOUNT, "1");
private static final Map<String, String> DEFAULTS = Map.of(DELAY, "10", INTERVAL, "10", AMOUNT, "1");
private static final int MAX_BYTES = 1_048_576;
private static final long MAX_SECONDS = 31_536_000L;
@@ -0,0 +1,34 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
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 prices for future party transactions. */
public final class PartyEconomySettings implements AutoCloseable {
public static final String REGISTRATION="party-registration-price", LEADER_CHANGE="party-leader-change-price";
private static final List<String> KEYS=List.of(LEADER_CHANGE,REGISTRATION);
private static final Map<String,String> DEFAULTS=Map.of(REGISTRATION,"10",LEADER_CHANGE,"10");
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-party-economy-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 PartyEconomySettings(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.get(key)));validate(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 int registrationPrice(){return whole(effective.get(REGISTRATION));} public int leaderChangePrice(){return whole(effective.get(LEADER_CHANGE));}
public CompletionStage<Update> set(String key,String raw){if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown party price");String accepted;try{accepted=Integer.toString(whole(raw));}catch(RuntimeException invalid){throw new IllegalArgumentException("Party price must be a whole number from 0 through 2147483647",invalid);}var preview=new LinkedHashMap<>(effective);preview.put(key,accepted);validate(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);validate(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;validate(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
private static void validate(Map<String,String> values){for(String key:KEYS)whole(values.get(key));}
private static int whole(String value){var number=new java.math.BigInteger(value);if(number.signum()<0||number.compareTo(java.math.BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return number.intValueExact();}
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);}
}
@@ -0,0 +1,35 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.party.PartyNames;
import java.io.IOException;
import java.math.BigInteger;
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 recovery plans spanning a character debit and a party mutation. */
public final class PartyOperationStore implements AutoCloseable {
public enum Kind{CREATE,TRANSFER}
public record Plan(UUID id,Kind kind,UUID actor,BigInteger expectedRevision,BigInteger amount,String name,UUID party,UUID target,BigInteger expectedPartyRevision){public Plan{Objects.requireNonNull(id);Objects.requireNonNull(kind);Objects.requireNonNull(actor);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(amount);if(expectedRevision.signum()<0||amount.signum()<0||kind==Kind.CREATE&&(PartyNames.parse(name).isEmpty()||party!=null||target!=null||expectedPartyRevision!=null)||kind==Kind.TRANSFER&&(party==null||target==null||name!=null||expectedPartyRevision==null||expectedPartyRevision.signum()<0))throw new IllegalArgumentException("Invalid party operation");}}
private final Path directory;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-party-operation-persistence"));private volatile boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public PartyOperationStore(Path data){directory=data.toAbsolutePath().resolve("party-operations");}
public static UUID refundId(UUID operation){return UUID.nameUUIDFromBytes(("party-refund:"+operation).getBytes(StandardCharsets.UTF_8));}
public CompletionStage<Plan> prepare(Plan plan){return submit(()->{Files.createDirectories(directory);if(plan.kind()==Kind.CREATE){String canonical=PartyNames.parse(plan.name()).orElseThrow().canonical();for(var pending:loadBlocking())if(pending.kind()==Kind.CREATE&&PartyNames.parse(pending.name()).orElseThrow().canonical().equals(canonical)&&!pending.id().equals(plan.id()))throw new IOException("Party name already reserved");}Path target=file(plan.id());if(Files.exists(target)){var existing=decode(Files.readString(target));if(!existing.equals(plan))throw new IOException("Operation identity collision");return existing;}write(target,encode(plan));return plan;});}
public CompletionStage<Void> complete(UUID id){return submit(()->{if(Files.deleteIfExists(file(id)))DurableFiles.forceDirectory(directory);return null;});}
public CompletionStage<List<Plan>> load(){return submit(this::loadBlocking);}
private List<Plan> loadBlocking()throws IOException{if(Files.notExists(directory))return List.of();var result=new ArrayList<Plan>();try(var files=Files.list(directory)){for(var path:files.filter(value->value.getFileName().toString().endsWith(".yml")).sorted().toList()){if(Files.size(path)>65536)throw new IOException("Party operation too large");result.add(decode(Files.readString(path,StandardCharsets.UTF_8)));}}return List.copyOf(result);}
private Path file(UUID id){return directory.resolve(id+".yml");}
private static String encode(Plan p){Map<String,Object> row=new LinkedHashMap<>();row.put("schema-version",1);row.put("operation-id",p.id().toString());row.put("kind",p.kind().name());row.put("actor",p.actor().toString());row.put("expected-revision",p.expectedRevision().toString());row.put("amount",p.amount().toString());if(p.name()!=null)row.put("name",p.name());if(p.party()!=null)row.put("party",p.party().toString());if(p.target()!=null)row.put("target",p.target().toString());if(p.expectedPartyRevision()!=null)row.put("expected-party-revision",p.expectedPartyRevision().toString());return yaml().dump(row);}
private static Plan decode(String input){Object loaded=yaml().load(input);if(!(loaded instanceof Map<?,?> row)||!"1".equals(String.valueOf(row.get("schema-version"))))throw new IllegalArgumentException("Invalid party operation schema");return new Plan(UUID.fromString(text(row,"operation-id")),Kind.valueOf(text(row,"kind")),UUID.fromString(text(row,"actor")),new BigInteger(text(row,"expected-revision")),new BigInteger(text(row,"amount")),optional(row,"name"),uuid(row,"party"),uuid(row,"target"),row.containsKey("expected-party-revision")?new BigInteger(text(row,"expected-party-revision")):null);}
private static String text(Map<?,?> row,String key){Object value=row.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
private static String optional(Map<?,?> row,String key){return row.containsKey(key)?text(row,key):null;}private static UUID uuid(Map<?,?> row,String key){String value=optional(row,key);return value==null?null:UUID.fromString(value);}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(65536);return new Yaml(new SafeConstructor(options));}
private static void write(Path target,String content)throws IOException{Path temporary=Files.createTempFile(target.getParent(),"party-operation-",".tmp");try{byte[] bytes=content.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,target,StandardCopyOption.ATOMIC_MOVE);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic party-operation persistence is unavailable",unsupported);}DurableFiles.forceDirectory(target.getParent());}finally{Files.deleteIfExists(temporary);}}
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Party operation store closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
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,186 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable account-owned routine combat/effect feedback preference. */
public final class PlayerFeedbackPreferences implements AutoCloseable {
private static final String FILE = "player-preferences.yml";
private static final String PLAYERS = "players";
private static final String KEY = "combat-feedback";
private static final int MAX_BYTES = 1_048_576;
public record Update(UUID account, boolean enabled, long sequence) {
public Update { Objects.requireNonNull(account); }
}
private final Path directory;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-player-feedback-preferences"));
private final Map<UUID, Boolean> effective = new java.util.HashMap<>();
private final Map<UUID, Long> published = new java.util.HashMap<>();
private long persistedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public PlayerFeedbackPreferences(Path directory) throws IOException {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
effective.putAll(loadEnabled());
}
public synchronized boolean enabled(UUID account) {
Objects.requireNonNull(account);
return effective.getOrDefault(account, true);
}
public CompletionStage<Update> set(UUID account, boolean enabled) {
Objects.requireNonNull(account);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(account, enabled);
return new Update(account, enabled, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Combat feedback preference save refused or failed; effective setting unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
}
}
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (closed) return false;
long previous = published.getOrDefault(update.account(), 0L);
if (update.sequence() <= previous) return false;
effective.put(update.account(), update.enabled());
published.put(update.account(), update.sequence());
return true;
}
private Map<UUID, Boolean> loadEnabled() throws IOException {
Map<String, Object> root = readRoot();
Object rawPlayers = root.get(PLAYERS);
if (rawPlayers == null) return Map.of();
if (!(rawPlayers instanceof Map<?, ?> players)) throw new IOException("Invalid player preferences");
var result = new LinkedHashMap<UUID, Boolean>();
for (var entry : players.entrySet()) {
if (!(entry.getKey() instanceof String rawId)) throw new IOException("Invalid player preference identity");
UUID account;
try { account = UUID.fromString(rawId); }
catch (IllegalArgumentException invalid) { throw new IOException("Invalid player preference identity", invalid); }
if (!(entry.getValue() instanceof Map<?, ?> values)) throw new IOException("Invalid player preference entry");
Object raw = values.get(KEY);
if (raw == null) continue;
if (!(raw instanceof Boolean value)) throw new IOException("Invalid combat feedback preference");
result.put(account, value);
}
return result;
}
private void save(UUID account, boolean enabled) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
Path lockFile = directory.resolve(".player-preferences.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe preference lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS);
var ignored = channel.tryLock()) {
if (ignored == null) throw new IOException("Player preferences busy");
Map<String, Object> root = readRoot();
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
values.put(KEY, enabled);
players.put(account.toString(), values);
root.put(PLAYERS, players);
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".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);
}
Files.move(temporary, directory.resolve(FILE), StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) {
throw new IOException("Player preferences busy", busy);
}
}
private Map<String, Object> readRoot() throws IOException {
Path file = directory.resolve(FILE);
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return new LinkedHashMap<>();
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe player preferences");
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("Player preferences too large");
Object loaded = yaml().load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
return stringMap(loaded, "player preference root");
}
private static Map<String, Object> stringMap(Object raw, String label) throws IOException {
var result = new LinkedHashMap<String, Object>();
if (raw == null) return result;
if (!(raw instanceof Map<?, ?> map)) throw new IOException("Invalid " + label);
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid " + label + " key");
result.put(key, entry.getValue());
}
return result;
}
private static Yaml yaml() {
var options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20);
options.setCodePointLimit(MAX_BYTES);
return new Yaml(new SafeConstructor(options));
}
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);
}
}
@@ -33,7 +33,7 @@ public final class ShieldBashSettings implements AutoCloseable {
public static final String HORIZONTAL = "shield-bash-horizontal-knockback";
public static final String VERTICAL = "shield-bash-minimum-vertical-knockback";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, RANGE, HORIZONTAL, VERTICAL);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "6", RANGE, "3",
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "3", RANGE, "3",
HORIZONTAL, "0.8", VERTICAL, "0.35");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
@@ -0,0 +1,55 @@
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());}
return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters);
}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());
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());}
}
@@ -0,0 +1,91 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.shop.ArmorCatalog;
import games.dmg.fantasystick.shop.ArmorProduct;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Forced atomic persistence for the one shared armor catalog. */
public final class YamlArmorCatalogRepository implements ArmorCatalogRepository {
private final Path file;
public YamlArmorCatalogRepository(Path data){file=data.toAbsolutePath().resolve("armor-catalog.yml");}
@Override public Optional<ArmorCatalog> load() throws IOException {
if(Files.notExists(file))return Optional.empty();
try {
if(Files.size(file)>1_048_576)throw new IllegalArgumentException("Catalog too large");
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));
if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(String.valueOf(root.get("schema-version")))!=1)
throw new IllegalArgumentException("Invalid schema");
var products=new LinkedHashMap<ArmorProduct,ArmorCatalog.Offer>();
for(Object value:list(root.get("products"))) {
var row=map(value);
var product=parseProduct(text(row,"product"));
if(products.put(product,new ArmorCatalog.Offer(new BigInteger(text(row,"price")),
Integer.parseInt(text(row,"remaining"))))!=null)throw new IllegalArgumentException("Duplicate product");
}
var transactions=new LinkedHashMap<UUID,ArmorCatalog.Transaction>();
Object savedTransactions=root.get("transactions");
if(savedTransactions!=null)for(Object value:list(savedTransactions)) {
var row=map(value);var id=UUID.fromString(text(row,"operation-id"));
var transaction=new ArmorCatalog.Transaction(parseProduct(text(row,"product")),
ArmorCatalog.TransactionState.valueOf(text(row,"state")));
if(transactions.put(id,transaction)!=null)throw new IllegalArgumentException("Duplicate operation");
}
return Optional.of(new ArmorCatalog(LocalDate.parse(text(root,"catalog-day")),
UUID.fromString(text(root,"revision")),Instant.parse(text(root,"refreshed-at")),
ArmorCatalog.RefreshReason.valueOf(text(root,"refresh-reason")),products,transactions));
} catch(RuntimeException invalid) { throw new IOException("Invalid armor catalog",invalid); }
}
@Override public void save(ArmorCatalog catalog) throws IOException {
Objects.requireNonNull(catalog);
Map<String,Object> root=new LinkedHashMap<>();
root.put("schema-version",1);
root.put("catalog-day",catalog.day().toString());
root.put("revision",catalog.revision().toString());
root.put("refreshed-at",catalog.refreshedAt().toString());
root.put("refresh-reason",catalog.reason().name());
root.put("products",catalog.products().entrySet().stream().sorted(Map.Entry.comparingByKey(
Comparator.comparing((ArmorProduct value)->value.slot().name()).thenComparing(value->value.tier().name())))
.map(entry->{Map<String,Object> row=new LinkedHashMap<>();row.put("product",product(entry.getKey()));
row.put("price",entry.getValue().price().toString());row.put("remaining",Integer.toString(entry.getValue().remaining()));return row;}).toList());
root.put("transactions",catalog.transactions().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->{
Map<String,Object> row=new LinkedHashMap<>();row.put("operation-id",entry.getKey().toString());
row.put("product",product(entry.getValue().product()));row.put("state",entry.getValue().state().name());return row;}).toList());
write(yaml().dump(root));
}
private void write(String content)throws IOException {
Files.createDirectories(file.getParent());
Path temporary=Files.createTempFile(file.getParent(),"armor-catalog-",".tmp");
try {
byte[] bytes=content.getBytes(StandardCharsets.UTF_8);
try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){
var buffer=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 armor catalog persistence unavailable",unsupported);}
DurableFiles.forceDirectory(file.getParent());
} finally { Files.deleteIfExists(temporary); }
}
private static String product(ArmorProduct product){return product.slot().name()+"-"+product.tier().name();}
private static ArmorProduct parseProduct(String value){int split=value.indexOf('-');if(split<1)throw new IllegalArgumentException();return new ArmorProduct(
GearSlot.valueOf(value.substring(0,split)),GearTier.valueOf(value.substring(split+1)));}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);
options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}
private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
}
@@ -132,6 +132,7 @@ public final class YamlCharacterRepository {
var attributes = Files.readAttributes(temporary, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
// Never fall back to an in-place, partially written character record.
replacement.move(temporary, file(character.id()));
DurableFiles.forceDirectory(directory);
return new Creation(character, attributes, bytes);
} finally {
Files.deleteIfExists(temporary);
@@ -0,0 +1,9 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.shop.FantasyArmorer;import java.io.*;import java.nio.charset.StandardCharsets;import java.nio.file.*;import java.util.*;import org.yaml.snakeyaml.*;import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Forced atomic desired-state adapter for Fantasy Armorer registrations. */
public final class YamlFantasyArmorerRepository{
private final Path file;public YamlFantasyArmorerRepository(Path data){file=data.toAbsolutePath().resolve("fantasy-armorers.yml");}
public List<FantasyArmorer> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(root.get("schema-version").toString())!=1)throw new IllegalArgumentException();var result=new ArrayList<FantasyArmorer>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new FantasyArmorer(UUID.fromString(text(row,"shop-id")),text(row,"name"),UUID.fromString(text(row,"world")),Double.parseDouble(text(row,"x")),Double.parseDouble(text(row,"y")),Double.parseDouble(text(row,"z")),Float.parseFloat(text(row,"yaw")),Float.parseFloat(text(row,"pitch"))));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Fantasy Armorer file",invalid);}}
public void save(List<FantasyArmorer> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(FantasyArmorer::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("shop-id",value.id().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"armorers-",".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 Armorer persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
private static void validate(List<FantasyArmorer> values){var ids=new HashSet<UUID>();if(values.stream().anyMatch(value->!ids.add(value.id())))throw new IllegalArgumentException("Duplicate Armorer identity");}private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException();}
}
@@ -0,0 +1,25 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.party.PartyRegistrar;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Atomic desired-state adapter for administrator-managed Party Registrars. */
public final class YamlPartyRegistrarRepository {
private final Path file;
public YamlPartyRegistrarRepository(Path data){file=data.toAbsolutePath().resolve("party-registrars.yml");}
public List<PartyRegistrar> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||number(root.get("schema-version"))!=1)throw new IllegalArgumentException();var result=new ArrayList<PartyRegistrar>();for(Object value:list(root.get("registrars"))){var row=map(value);result.add(new PartyRegistrar(UUID.fromString(text(row.get("registrar-id"))),text(row.get("name")),UUID.fromString(text(row.get("world"))),decimal(row.get("x")),decimal(row.get("y")),decimal(row.get("z")),(float)decimal(row.get("yaw")),(float)decimal(row.get("pitch"))));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Party Registrar file",invalid);}}
public void save(List<PartyRegistrar> registrars)throws IOException{validate(registrars);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("registrars",registrars.stream().sorted(Comparator.comparing(PartyRegistrar::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("registrar-id",value.id().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"party-registrars-",".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 Registrar persistence is unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
private static void validate(List<PartyRegistrar> values){var ids=new HashSet<UUID>();if(values.stream().anyMatch(value->!ids.add(value.id())))throw new IllegalArgumentException("Duplicate Registrar identity");}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);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 number(Object value){return Integer.parseInt(value.toString());}
private static double decimal(Object value){return Double.parseDouble(text(value));}
}
@@ -0,0 +1,116 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.party.CharacterParty;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Blocking, atomic whole-roster YAML adapter; callers keep it off the server tick thread. */
public final class YamlPartyRepository {
private final Path file;
public YamlPartyRepository(Path pluginDataDirectory) {
file = pluginDataDirectory.toAbsolutePath().resolve("parties.yml");
}
public List<CharacterParty> load() throws IOException {
if (Files.notExists(file)) return List.of();
String input = Files.readString(file, StandardCharsets.UTF_8);
if (input.length() > 1_048_576) throw new IOException("Party file exceeds size limit");
try {
Object decoded = yaml().load(input);
if (!(decoded instanceof Map<?, ?> root) || number(root.get("schema-version")).intValueExact() != 1) {
throw new IllegalArgumentException("Unsupported party schema");
}
var parties = new ArrayList<CharacterParty>();
for (Object value : list(root.get("parties"))) parties.add(decode(object(value)));
validate(parties);
return List.copyOf(parties);
} catch (RuntimeException malformed) {
throw new IOException("Invalid party file", malformed);
}
}
public void save(List<CharacterParty> parties) throws IOException {
validate(parties);
Map<String, Object> root = new LinkedHashMap<>();
root.put("schema-version", 1);
var rows = new ArrayList<Map<String, Object>>();
parties.stream().sorted(java.util.Comparator.comparing(CharacterParty::id)).forEach(party -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("party-id", party.id().toString()); row.put("revision", party.revision().toString());
row.put("display-name", party.displayName()); row.put("canonical-name", party.canonicalName());
row.put("leader", party.leader().toString());
if(party.lastPaidOperation()!=null)row.put("last-paid-operation",party.lastPaidOperation().toString());
row.put("members", party.members().stream().sorted().map(UUID::toString).toList());
row.put("invitations", party.invitations().stream().sorted(java.util.Comparator.comparing(CharacterParty.Invitation::id))
.map(invitation -> Map.of("invitation-id", invitation.id().toString(),
"character", invitation.character().toString())).toList());
rows.add(row);
});
root.put("parties", rows);
Files.createDirectories(file.getParent());
Path temporary = Files.createTempFile(file.getParent(), "parties-", ".tmp");
try {
byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,java.nio.file.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 (java.nio.file.AtomicMoveNotSupportedException unsupported) {
throw new IOException("Atomic party persistence is unavailable", unsupported);
}
DurableFiles.forceDirectory(file.getParent());
} finally { Files.deleteIfExists(temporary); }
}
private static CharacterParty decode(Map<?, ?> row) {
var invitations = list(row.get("invitations")).stream().map(YamlPartyRepository::object)
.map(invitation -> new CharacterParty.Invitation(UUID.fromString(text(invitation.get("invitation-id"))),
UUID.fromString(text(invitation.get("character"))))).toList();
Set<UUID> members = list(row.get("members")).stream().map(YamlPartyRepository::text)
.map(UUID::fromString).collect(java.util.stream.Collectors.toUnmodifiableSet());
return new CharacterParty(UUID.fromString(text(row.get("party-id"))), number(row.get("revision")),
text(row.get("display-name")), text(row.get("canonical-name")), UUID.fromString(text(row.get("leader"))),
members, invitations,row.containsKey("last-paid-operation")?UUID.fromString(text(row.get("last-paid-operation"))):null);
}
private static void validate(List<CharacterParty> parties) {
var ids = new HashSet<UUID>(); var names = new HashSet<String>(); var members = new HashSet<UUID>();
for (var party : parties) {
if (!ids.add(party.id()) || !names.add(party.canonicalName())
|| party.members().stream().anyMatch(member -> !members.add(member))) {
throw new IllegalArgumentException("Duplicate party identity, name or membership");
}
}
}
private static Yaml yaml() {
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(0); options.setCodePointLimit(1_048_576);
return new Yaml(new SafeConstructor(options));
}
private static Map<?, ?> object(Object value) {
if (value instanceof Map<?, ?> map) return map; throw new IllegalArgumentException("Expected mapping");
}
private static List<?> list(Object value) {
if (value instanceof List<?> values) return values; throw new IllegalArgumentException("Expected list");
}
private static String text(Object value) {
if (value instanceof String text) return text; throw new IllegalArgumentException("Expected text");
}
private static BigInteger number(Object value) {
if (value instanceof Number || value instanceof BigInteger) return new BigInteger(value.toString());
return new BigInteger(text(value));
}
}
@@ -0,0 +1,89 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.training.TrainingDummy;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Atomic desired-state adapter for administrator-managed training dummies. */
public final class YamlTrainingDummyRepository {
private final Path file;
public YamlTrainingDummyRepository(Path data) {
file = data.toAbsolutePath().resolve("training-dummies.yml");
}
public List<TrainingDummy> load() throws IOException {
if (Files.notExists(file)) return List.of();
try {
Object decoded = yaml().load(Files.readString(file, StandardCharsets.UTF_8));
if (!(decoded instanceof Map<?, ?> root) || number(root.get("schema-version")) != 1) {
throw new IllegalArgumentException();
}
var result = new ArrayList<TrainingDummy>();
for (Object value : list(root.get("dummies"))) {
var row = map(value);
result.add(new TrainingDummy(UUID.fromString(text(row.get("dummy-id"))), text(row.get("name")),
UUID.fromString(text(row.get("world"))), decimal(row.get("x")), decimal(row.get("y")),
decimal(row.get("z")), (float) decimal(row.get("yaw")), (float) decimal(row.get("pitch"))));
}
validate(result); return List.copyOf(result);
} catch (RuntimeException invalid) {
throw new IOException("Invalid training dummy file", invalid);
}
}
public void save(List<TrainingDummy> dummies) throws IOException {
validate(dummies);
Map<String, Object> root = new LinkedHashMap<>(); root.put("schema-version", 1);
root.put("dummies", dummies.stream().sorted(Comparator.comparing(TrainingDummy::id)).map(value -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("dummy-id", value.id().toString()); row.put("name", value.name());
row.put("world", value.world().toString()); row.put("x", Double.toString(value.x()));
row.put("y", Double.toString(value.y())); row.put("z", Double.toString(value.z()));
row.put("yaw", Float.toString(value.yaw())); row.put("pitch", Float.toString(value.pitch())); return row;
}).toList());
Files.createDirectories(file.getParent());
Path temporary = Files.createTempFile(file.getParent(), "training-dummies-", ".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 training dummy persistence is unavailable", unsupported);
}
DurableFiles.forceDirectory(file.getParent());
} finally { Files.deleteIfExists(temporary); }
}
private static void validate(List<TrainingDummy> values) {
var ids = new HashSet<UUID>();
if (values.stream().anyMatch(value -> !ids.add(value.id()))) {
throw new IllegalArgumentException("Duplicate training dummy identity");
}
}
private static Yaml yaml() { var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(0); options.setCodePointLimit(1_048_576);
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 number(Object value) { return Integer.parseInt(value.toString()); }
private static double decimal(Object value) { return Double.parseDouble(text(value)); }
}
@@ -0,0 +1,87 @@
package games.dmg.fantasystick.presentation;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.ability.AbilityFeedback;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
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;
/** Account-filtered chat presentation for authoritative Fantasy Stick effect outcomes. */
public final class CombatFeedback implements AbilityFeedback {
private final PlayerFeedbackPreferences preferences;
private final EquipmentSessions sessions;
public CombatFeedback(PlayerFeedbackPreferences preferences, EquipmentSessions sessions) {
this.preferences = Objects.requireNonNull(preferences);
this.sessions = Objects.requireNonNull(sessions);
}
@Override public void report(AbilityDefinition definition, ControlInput input, AbilityReport.Type type,
Entity target, BigDecimal amount) {
Objects.requireNonNull(definition); Objects.requireNonNull(input);
report(definition.name(), input.player(), type, target, amount);
}
public void report(String effect, Player source, AbilityReport.Type type, Entity target, BigDecimal amount) {
Objects.requireNonNull(effect); Objects.requireNonNull(source); Objects.requireNonNull(type);
Objects.requireNonNull(target); Objects.requireNonNull(amount);
if (amount.signum() <= 0 || !Double.isFinite(amount.doubleValue())) return;
String message = message(effect, type, name(target), amount);
if (preferences.enabled(source.getUniqueId())) send(source, message);
if (target instanceof Player recipient && recipient != source && recipientEffect(type)
&& preferences.enabled(recipient.getUniqueId())) send(recipient, message);
}
private String name(Entity entity) {
if (entity instanceof Player player) {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
if (active != null) {
var record = sessions.currentRecord(active).orElse(null);
if (record != null && !record.name().isBlank()) return record.name();
}
}
String nativeName = entity.getName();
if (nativeName != null && !nativeName.isBlank()) return nativeName;
var entityType = entity.getType();
if (entityType == null) return "Target";
String value = entityType.name().toLowerCase(Locale.ROOT).replace('_', ' ');
var words = value.split(" ");
var result = new StringBuilder();
for (String word : words) {
if (!result.isEmpty()) result.append(' ');
if (!word.isEmpty()) result.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return result.toString();
}
private static String message(String effect, AbilityReport.Type type, String target, BigDecimal amount) {
String value = amount.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
return switch (type) {
case DAMAGE -> effect + " did " + value + " damage to " + target + ".";
case HEALING -> effect + " healed " + target + " for " + value + " health.";
case CLEANSING -> effect + " removed " + value + " harmful "
+ (amount.compareTo(BigDecimal.ONE) == 0 ? "effect" : "effects") + " from " + target + ".";
case PREVENTION -> effect + " prevented " + value + " damage to " + target + ".";
case BOOST_USED -> effect + " added " + value + " damage against " + target + ".";
};
}
private static void send(Player player, String message) {
try { player.sendMessage(Component.text(message, NamedTextColor.GRAY)); }
catch (RuntimeException ignored) { }
}
private static boolean recipientEffect(AbilityReport.Type type) {
return type == AbilityReport.Type.HEALING || type == AbilityReport.Type.CLEANSING
|| type == AbilityReport.Type.PREVENTION;
}
}
@@ -0,0 +1,45 @@
package games.dmg.fantasystick.progression;
import java.math.BigInteger;
import java.util.Objects;
import java.util.random.RandomGenerator;
/** Reviewed inclusive coin ranges and target classification, independent of fantasy XP. */
public record CoinRules(long startingMin, long startingMax, long hostileMin, long hostileMax,
long bossMin, long bossMax) {
public enum Target { PASSIVE, NEUTRAL, HOSTILE, BOSS }
private static final long MAX = Integer.MAX_VALUE;
public CoinRules {
validate(startingMin, startingMax);
validate(hostileMin, hostileMax);
validate(bossMin, bossMax);
}
public static CoinRules defaults() { return new CoinRules(1, 6, 1, 5, 25, 50); }
public BigInteger starting(RandomGenerator random) {
return roll(startingMin, startingMax, random);
}
public BigInteger mob(Target target, RandomGenerator random) {
Objects.requireNonNull(target);
return switch (target) {
case PASSIVE, NEUTRAL -> BigInteger.ZERO;
case HOSTILE -> roll(hostileMin, hostileMax, random);
case BOSS -> roll(bossMin, bossMax, random);
};
}
private static BigInteger roll(long minimum, long maximum, RandomGenerator random) {
Objects.requireNonNull(random);
long value = minimum + random.nextLong(maximum - minimum + 1);
return BigInteger.valueOf(value);
}
private static void validate(long minimum, long maximum) {
if (minimum < 0 || maximum < minimum || maximum > MAX) {
throw new IllegalArgumentException("Coin ranges must be ordered whole numbers from 0 through 2147483647");
}
}
}
@@ -43,6 +43,28 @@ public final class CombatContributions implements AutoCloseable {
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
}
public boolean hasDirect(UUID target, UUID excludedCharacter, Instant at) {
Objects.requireNonNull(target); Objects.requireNonNull(at);
if (closed) return false;
var direct = targets.get(target);
return direct != null && direct.entrySet().stream().anyMatch(entry ->
!entry.getKey().equals(excludedCharacter) && rules.contributes(entry.getValue(), at));
}
/** Consume only direct attackers; support effects no longer establish reward eligibility. */
public Set<UUID> consumeDirect(UUID target, Instant deathAt) {
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
if (closed) return Set.of();
var contributions = targets.remove(target);
supports.remove(target);
if (contributions == null) return Set.of();
var eligible = new LinkedHashSet<UUID>();
contributions.forEach((character, acceptedAt) -> {
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
});
return Set.copyOf(eligible);
}
public Set<UUID> consume(UUID target, Instant deathAt) {
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
if (closed) return Set.of();
@@ -8,9 +8,9 @@ import java.util.Objects;
import java.util.UUID;
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
BigDecimal restrictedSpacing) {
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger hardPool,
BigInteger bossPool, BigDecimal restrictedSpacing) {
public enum Target { PASSIVE, ORDINARY, HARD, BOSS, FANTASY_CHARACTER }
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
@@ -26,16 +26,26 @@ public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool,
}
public CombatRewardRules {
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool); Objects.requireNonNull(hardPool);
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || hardPool.signum() < 0 || bossPool.signum() < 0
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
}
}
public CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
BigDecimal restrictedSpacing) {
this(passivePool, ordinaryPool, BigInteger.valueOf(20), bossPool, restrictedSpacing);
}
public static CombatRewardRules defaults() {
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(20),
BigInteger.valueOf(100), BigDecimal.valueOf(3));
}
public CombatRewardRules withHardPool(BigInteger pool) {
return new CombatRewardRules(passivePool, ordinaryPool, pool, bossPool, restrictedSpacing);
}
public BigInteger pool(Target target, int defeatedLevel) {
@@ -47,6 +57,7 @@ public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool,
return switch (target) {
case PASSIVE -> passivePool;
case ORDINARY -> ordinaryPool;
case HARD -> hardPool;
case BOSS -> bossPool;
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
};
@@ -0,0 +1,65 @@
package games.dmg.fantasystick.progression;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.random.RandomGenerator;
/** Exact two-stage quotient/remainder allocation; spacing and caps apply only after conservation. */
public final class RewardAllocator {
private RewardAllocator() { }
public record Unit<T>(Object key, List<T> recipients) {
public Unit {
Objects.requireNonNull(key);
recipients = List.copyOf(recipients);
if (recipients.isEmpty() || recipients.stream().anyMatch(Objects::isNull)
|| recipients.size() != new HashSet<>(recipients).size()) {
throw new IllegalArgumentException("A reward unit needs distinct recipients");
}
}
}
public static <T> Map<T, BigInteger> allocate(BigInteger pool, List<Unit<T>> units, RandomGenerator random) {
Objects.requireNonNull(pool); Objects.requireNonNull(units); Objects.requireNonNull(random);
if (pool.signum() < 0 || units.isEmpty()) throw new IllegalArgumentException("Invalid reward pool or units");
var keys = new HashSet<Object>();
var recipients = new HashSet<T>();
for (var unit : units) {
if (!keys.add(unit.key()) || unit.recipients().stream().anyMatch(recipient -> !recipients.add(recipient))) {
throw new IllegalArgumentException("Reward units and recipients must be distinct");
}
}
var unitShares = split(pool, units.size(), random);
var result = new LinkedHashMap<T, BigInteger>();
for (int index = 0; index < units.size(); index++) {
var unit = units.get(index);
var shares = split(unitShares.get(index), unit.recipients().size(), random);
for (int recipient = 0; recipient < unit.recipients().size(); recipient++) {
result.put(unit.recipients().get(recipient), shares.get(recipient));
}
}
return Map.copyOf(result);
}
private static List<BigInteger> split(BigInteger pool, int count, RandomGenerator random) {
var divisor = BigInteger.valueOf(count);
var quotient = pool.divide(divisor);
int remainder = pool.remainder(divisor).intValueExact();
var shares = new ArrayList<BigInteger>(java.util.Collections.nCopies(count, quotient));
var order = new ArrayList<Integer>();
for (int index = 0; index < count; index++) order.add(index);
for (int index = 0; index < remainder; index++) {
int selected = index + random.nextInt(count - index);
int recipient = order.get(selected);
order.set(selected, order.get(index));
order.set(index, recipient);
shares.set(recipient, shares.get(recipient).add(BigInteger.ONE));
}
return shares;
}
}
@@ -22,6 +22,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private final Consumer<Failure> diagnostics;
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
private games.dmg.fantasystick.ability.FrostShard frostShard;
private games.dmg.fantasystick.ability.AbilityRegistry abilities;
@@ -64,6 +66,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.persistence.PartySettings partySettings;
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.party.CombatExperience combatExperience;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
@@ -75,6 +84,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -301,6 +317,19 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
public void 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 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) {
@@ -348,12 +377,49 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterIssuer = Objects.requireNonNull(issuer);
}
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
games.dmg.fantasystick.party.PartyRegistrarController controller) {
if (closed || partyRegistrars != null) throw new IllegalStateException("Party Registrars already attached or command closed");
partyRegistrars=Objects.requireNonNull(directory); partyRegistrarController=Objects.requireNonNull(controller);
}
public void attachFireball(games.dmg.fantasystick.persistence.FireballSettings settings,
games.dmg.fantasystick.ability.Fireball effect) {
if (closed || fireballSettings != null) throw new IllegalStateException("Fireball already attached or command closed");
fireballSettings = Objects.requireNonNull(settings); fireball = Objects.requireNonNull(effect);
}
public void attachTrainingDummies(games.dmg.fantasystick.training.TrainingDummyDirectory directory,
games.dmg.fantasystick.training.TrainingDummyController controller) {
if (closed || trainingDummies != null) throw new IllegalStateException("Training dummies already attached or command closed");
trainingDummies = Objects.requireNonNull(directory); trainingDummyController = Objects.requireNonNull(controller);
}
public void attachArmorers(games.dmg.fantasystick.shop.FantasyArmorerDirectory directory,games.dmg.fantasystick.shop.FantasyArmorerController controller){if(closed||armorers!=null)throw new IllegalStateException("Armorers already attached or command closed");armorers=Objects.requireNonNull(directory);armorerController=Objects.requireNonNull(controller);}
public void attachArmorMarket(games.dmg.fantasystick.shop.ArmorMarket market){if(closed||armorMarket!=null)throw new IllegalStateException("Armor market already attached or command closed");armorMarket=Objects.requireNonNull(market);}
@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(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;
}
if (args.length > 0 && args[0].equals("party-registrar")) {
partyRegistrar(sender,args); return true;
}
if (args.length > 0 && args[0].equals("config")) {
configuration(sender, args);
return true;
@@ -478,6 +544,143 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
private void shop(CommandSender sender,String[] args){
if(armorers==null||armorerController==null||args.length<2){shopUsage(sender);return;}
if(args[1].equals("restock")&&args.length==2&&armorMarket!=null){armorMarket.restock().whenComplete((catalog,failure)->{
if(failure!=null)report(null,"shop-restock",failure);if(closed)return;try{nextTick.accept(()->{
if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Fantasy Armorer restock failed; the prior catalog is unchanged.");
else sender.sendMessage("Restocked all Fantasy Armorers with catalog "+catalog.revision()+"; the next automatic restock remains 00:00 UTC.");
});}catch(RuntimeException rejected){report(null,"shop-restock-response",rejected);}});return;}
if(args[1].equals("list")&&args.length==2){var values=armorers.all();if(values.isEmpty())sender.sendMessage("No Fantasy Armorers are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Fantasy Armorer":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.shop.FantasyArmorer> operation;try{operation=armorers.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer name is invalid.");return;}operation.whenComplete((saved,failure)->placeShopResponse(player,location,saved,failure));return;}
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,removed,failure));return;}
shopUsage(sender);
}
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fantasystick shop <place [name...]|remove <shop-uuid>|list|restock>");}
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
private void trainingDummy(CommandSender sender, String[] args) {
if (trainingDummies == null || trainingDummyController == null || args.length < 2) {
trainingDummyUsage(sender); return;
}
if (args[1].equals("list") && args.length == 2) {
var values = trainingDummies.all();
if (values.isEmpty()) sender.sendMessage("No training dummies are registered.");
else for (var value : values) sender.sendMessage(value.id() + " " + value.displayName() + " @ "
+ value.world() + " " + value.x() + " " + value.y() + " " + value.z());
return;
}
if (args[1].equals("place") && sender instanceof org.bukkit.entity.Player player) {
String name = args.length < 3 ? "" : String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
var location = player.getLocation().clone();
try {
trainingDummies.place(name, location.getWorld().getUID(), location.getX(), location.getY(),
location.getZ(), location.getYaw(), location.getPitch())
.whenComplete((saved, failure) -> placeDummyResponse(player, location, saved, failure));
} catch (IllegalArgumentException invalid) { sender.sendMessage("Training dummy name is invalid."); }
return;
}
if (args[1].equals("remove") && args.length == 3) {
UUID id;
try { id = UUID.fromString(args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("Training dummy UUID is invalid."); return; }
trainingDummies.remove(id).whenComplete((removed, failure) -> removeDummyResponse(sender, id, removed, failure));
return;
}
trainingDummyUsage(sender);
}
private static void trainingDummyUsage(CommandSender sender) {
sender.sendMessage("Usage: /fantasystick training-dummy <place [name...]|remove <dummy-uuid>|list>");
}
private void placeDummyResponse(org.bukkit.entity.Player player, org.bukkit.Location location,
games.dmg.fantasystick.training.TrainingDummy saved, Throwable failure) {
if (failure != null) report(player.getUniqueId(), "training-dummy-place", failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure != null) {
if (player.isOnline()) player.sendMessage("Training dummy placement failed; no record or entity changed.");
return;
}
if (!player.isOnline() || !player.hasPermission(PERMISSION)) {
trainingDummies.remove(saved.id()).whenComplete((ignored, rollbackFailure) -> {
if (rollbackFailure != null) report(player.getUniqueId(), "training-dummy-permission-rollback", rollbackFailure);
if (!closed) nextTick.accept(() -> {
trainingDummyController.removeLoaded(saved.id());
if (player.isOnline()) player.sendMessage("Training dummy placement was cancelled because permission changed.");
});
});
return;
}
try {
int chunkX = location.getBlockX() >> 4, chunkZ = location.getBlockZ() >> 4;
if (location.getWorld().isChunkLoaded(chunkX, chunkZ)) {
trainingDummyController.reconcile(location.getWorld().getChunkAt(chunkX, chunkZ));
}
player.sendMessage("Placed training dummy " + saved.id() + ".");
} catch (RuntimeException entityFailure) {
report(player.getUniqueId(), "training-dummy-entity", entityFailure);
trainingDummies.remove(saved.id()).whenComplete((ignored, rollbackFailure) -> {
if (rollbackFailure != null) report(player.getUniqueId(), "training-dummy-rollback", rollbackFailure);
if (!closed) nextTick.accept(() -> {
trainingDummyController.removeLoaded(saved.id());
if (player.isOnline()) player.sendMessage("Training dummy placement failed; no record or entity changed.");
});
});
}
}); } catch (RuntimeException rejected) { report(player.getUniqueId(), "training-dummy-response", rejected); }
}
private void removeDummyResponse(CommandSender sender, UUID id,
java.util.Optional<games.dmg.fantasystick.training.TrainingDummy> removed, Throwable failure) {
if (failure != null) report(null, "training-dummy-remove", failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure != null) { sender.sendMessage("Training dummy removal failed; record and entity are unchanged."); return; }
if (removed.isEmpty()) { sender.sendMessage("Training dummy not found."); return; }
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
restoreDummy(sender, removed.orElseThrow(), "Training dummy removal was cancelled because permission changed.");
return;
}
try { trainingDummyController.removeLoaded(id); sender.sendMessage("Removed training dummy " + id + "."); }
catch (RuntimeException entityFailure) {
report(null, "training-dummy-entity", entityFailure);
restoreDummy(sender, removed.orElseThrow(), "Training dummy removal failed; record and entity are unchanged.");
}
}); } catch (RuntimeException rejected) { report(null, "training-dummy-response", rejected); }
}
private void restoreDummy(CommandSender sender, games.dmg.fantasystick.training.TrainingDummy removed, String message) {
trainingDummies.restore(removed).whenComplete((restored, rollbackFailure) -> {
if (rollbackFailure != null) report(null, "training-dummy-rollback", rollbackFailure);
if (closed) return;
nextTick.accept(() -> {
if (rollbackFailure == null) {
var world = server.getWorld(restored.world());
int chunkX = ((int) Math.floor(restored.x())) >> 4, chunkZ = ((int) Math.floor(restored.z())) >> 4;
if (world != null && world.isChunkLoaded(chunkX, chunkZ)) trainingDummyController.reconcile(world.getChunkAt(chunkX, chunkZ));
}
sender.sendMessage(message);
});
});
}
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{partyRegistrarController.removeLoaded(id);sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
private void partyRegistrar(CommandSender sender,String[] args){
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fantasystick party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
if(args[1].equals("list")&&args.length==2){var values=partyRegistrars.all();if(values.isEmpty())sender.sendMessage("No Party Registrars are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Party Registrar":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.party.PartyRegistrar> operation;try{operation=partyRegistrars.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Party Registrar name is invalid.");return;}operation.whenComplete((saved,failure)->placeRegistrarResponse(player,location,saved,failure));return;}
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party Registrar UUID is invalid.");return;}partyRegistrars.remove(id).whenComplete((removed,failure)->removeRegistrarResponse(sender,id,removed,failure));return;}
sender.sendMessage("Usage: /fantasystick party-registrar <place [name...]|remove <registrar-uuid>|list>");
}
private void give(CommandSender sender, String[] args) {
if (args.length != 2 || args[1].isBlank() || characterIssuer == null) {
sender.sendMessage("Usage: /fantasystick give <online-player>"); return;
@@ -508,6 +711,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)) { switchDelayConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)) { roleplayConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
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
@@ -573,6 +785,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (meteorSettings != null && games.dmg.fantasystick.persistence.MeteorSettings.keys().contains(args[1])) {
meteorConfiguration(sender, args); return;
}
if (fireballSettings != null && games.dmg.fantasystick.persistence.FireballSettings.keys().contains(args[1])) {
fireballConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -607,6 +822,33 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void fireballConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + fireballSettings.value(key) + " (applies to future Fireball casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FireballSettings.Update> operation;
try { operation = fireballSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Fireball 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;
if (failure == null && fireballSettings.publish(saved)) {
fireball.configure(fireballSettings.settings()); abilities.replace(fireball.definition());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Fireball casts; active projectiles and deadlines are unchanged."
: "Fireball configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void outOfCombatRecoveryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
@@ -1100,6 +1342,46 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){if(sheetResourcePackSettings==null){configurationUsage(sender);return;}String key=args[1];if(args.length==2){sender.sendMessage(key+"="+sheetResourcePackSettings.value(key));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;try{operation=sheetResourcePackSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-sheet-resource-pack",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=sheetResourcePackSettings.publish(saved);if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers());}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+partyEconomySettings.value(key)+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&partyEconomySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
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);}});}
@@ -1142,9 +1424,28 @@ 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 (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("config", "experience", "give", "inspect", "recover", "revive") : List.of("config", "experience", "give", "inspect", "recover"), args[0]);
? List.of("adventure", "adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
: List.of("adventure-site", "config", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), args[0]);
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(
sender instanceof org.bukkit.entity.Player ? List.of("list", "place", "remove") : List.of("list", "remove"), args[1]);
if (args.length == 3 && args[0].equals("training-dummy") && args[1].equals("remove") && trainingDummies != null)
return matching(trainingDummies.all().stream().map(value -> value.id().toString()).toList(), args[2]);
if (args.length == 2 && args[0].equals("party-registrar")) return matching(
sender instanceof org.bukkit.entity.Player ? List.of("list","place","remove") : List.of("list","remove"),args[1]);
if (args.length == 3 && args[0].equals("party-registrar") && args[1].equals("remove") && partyRegistrars!=null)
return matching(partyRegistrars.all().stream().map(value->value.id().toString()).toList(),args[2]);
if (args.length == 2 && args[0].equals("experience")) return matching(List.of("give"), args[1]);
if (args.length >= 3 && args[0].equals("experience") && args[1].equals("give")) return experienceNames(args);
if (args.length == 2 && (args[0].equals("inspect") || args[0].equals("recover"))) return matching(
@@ -1154,10 +1455,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
if (args.length == 2 && args[0].equals("config")) {
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
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);
keys.add(games.dmg.fantasystick.persistence.HealthStealSettings.KEY);
keys.addAll(games.dmg.fantasystick.persistence.FireballSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
@@ -1183,6 +1490,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if(args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)&&switchDelaySettings!=null){String current=switchDelaySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT),args[2]);}
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)return matching(List.of("false","true"),args[2]);
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
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])) {
@@ -1207,6 +1522,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return matching(current.equals(games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT)
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT), args[2]);
}
if (fireballSettings != null && games.dmg.fantasystick.persistence.FireballSettings.keys().contains(args[1])) {
String current = fireballSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.FireballSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
String current = frostSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
@@ -4,6 +4,7 @@ import games.dmg.fantasystick.crafting.ManualDropDelivery;
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.EquipmentStorage;
import games.dmg.fantasystick.items.GearItems;
import games.dmg.fantasystick.items.SessionControls;
import io.papermc.paper.event.player.PlayerInventorySlotChangeEvent;
import java.util.Arrays;
@@ -41,6 +42,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private final Consumer<EquipmentSessions.Failure> diagnostics;
private final CharacterSheets sheets = new CharacterSheets();
private SessionControls controls = new SessionControls();
private GearItems gear;
private static final class Intent { boolean started; }
private final Map<UUID, Intent> swaps = new HashMap<>();
private final Map<UUID, UUID> observed = new HashMap<>();
@@ -71,6 +73,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
}
private boolean closed;
private Consumer<ControlInput> inputs = ignored -> { };
private java.util.function.Predicate<org.bukkit.entity.Entity> managedInteractionTarget = ignored -> false;
private record Draw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) { }
private final Map<UUID, Draw> draws = new HashMap<>();
@@ -94,6 +97,13 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
controls = new SessionControls(Objects.requireNonNull(registry));
}
public void attachGear(GearItems gear){if(closed||this.gear!=null)throw new IllegalStateException("Gear already attached or events closed");this.gear=Objects.requireNonNull(gear);}
public void attachManagedInteractionTarget(java.util.function.Predicate<org.bukkit.entity.Entity> target) {
if (closed) throw new IllegalStateException("Events closed");
managedInteractionTarget = Objects.requireNonNull(target);
}
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
@@ -343,6 +353,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (allowed && event.getHand() == EquipmentSlot.HAND && (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
if (selected != null && !selected.ability().isEmpty() && doorInteraction(event)) return;
if (selected != null && useGesture(selected)) {
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|| selected.ability().equals("mending_light")
@@ -357,6 +368,12 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
}
}
private boolean doorInteraction(org.bukkit.event.player.PlayerInteractEvent event) {
var block = event.getClickedBlock();
return event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK && block != null
&& block.getBlockData() instanceof org.bukkit.block.data.type.Door;
}
private void beginDraw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) {
UUID owner = player.getUniqueId();
if (closed || draws.containsKey(owner) || player.hasActiveItem()) return;
@@ -436,6 +453,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void useEntity(org.bukkit.event.player.PlayerInteractEntityEvent event) {
if (managedInteractionTarget.test(event.getRightClicked())) return;
var player = event.getPlayer();
var item = player.getInventory().getItem(event.getHand());
if (special(item)) event.setCancelled(true);
@@ -570,7 +588,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private boolean active(Player player) { return sessions.active(player.getUniqueId()).isPresent(); }
private boolean control(ItemStack item) { return pickupClaim(item, "control_move_") || controls.identify(item).isPresent(); }
private boolean special(ItemStack item) { return control(item) || box(item) != null; }
private boolean special(ItemStack item) { return control(item) || box(item) != null || gear != null && gear.gear(item); }
private static boolean ownInventory(Player player, Inventory inventory) {
return inventory instanceof PlayerInventory && inventory.equals(player.getInventory());
}
@@ -2,6 +2,7 @@ package games.dmg.fantasystick.session;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.EquipmentStorage;
import games.dmg.fantasystick.items.GearItems;
import games.dmg.fantasystick.items.SessionControls;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
@@ -42,6 +43,9 @@ public final class EquipmentSessions implements AutoCloseable {
private final EquipmentSessionStore receipts;
private Supplier<Duration> switchDelay;
private java.util.function.BooleanSupplier roleplayDisabled = () -> false;
private java.util.function.Predicate<UUID> pvpDefeat = ignored -> false;
private java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
ignored -> java.util.concurrent.CompletableFuture.completedStage(null);
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Consumer<Notice> notices;
@@ -50,6 +54,7 @@ public final class EquipmentSessions implements AutoCloseable {
private final SessionClaims claims = new SessionClaims();
private final EquipmentStorage equipment = new EquipmentStorage();
private SessionControls controls;
private GearItems gear;
private final CharacterSheets sheets = new CharacterSheets();
private final Map<UUID, Attempt> pending = new HashMap<>();
private final Map<UUID, Attempt> owned = new HashMap<>();
@@ -152,6 +157,8 @@ public final class EquipmentSessions implements AutoCloseable {
}
/** Install before exposing entry so later checks/exits capture the acknowledged live policy. */
public synchronized void attachGear(GearItems gear){if(closed||this.gear!=null)throw new IllegalStateException("Gear already attached or sessions closed");this.gear=Objects.requireNonNull(gear);}
public synchronized void attachSwitchDelay(Supplier<Duration> policy) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
switchDelay = Objects.requireNonNull(policy);
@@ -164,6 +171,16 @@ public final class EquipmentSessions implements AutoCloseable {
roleplayDisabled = Objects.requireNonNull(policy);
}
public synchronized void attachPvpDefeat(java.util.function.Predicate<UUID> predicate) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
pvpDefeat = Objects.requireNonNull(predicate);
}
public synchronized void attachPartyDefeat(java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> mutation) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
partyDefeat = Objects.requireNonNull(mutation);
}
/** Install before exposing entry so registered definitions drive delivered control metadata. */
public synchronized void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
@@ -269,8 +286,9 @@ public final class EquipmentSessions implements AutoCloseable {
var previous = attempt.record;
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
&& !refreshControls(attempt, session)) return false;
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())
|| !previous.passives().equals(saved.passives())) && !refreshControls(attempt, session)) return false;
return true;
}
@@ -299,7 +317,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]);
@@ -413,7 +431,7 @@ public final class EquipmentSessions implements AutoCloseable {
// Even malformed/foreign remnants are unsafe to pack as ordinary equipment.
return item.getItemMeta().getPersistentDataContainer().getKeys().stream().anyMatch(key ->
key.getNamespace().equals("fantasystick")
&& (key.getKey().startsWith("storage_") || key.getKey().startsWith("control_")));
&& (key.getKey().startsWith("storage_") || key.getKey().startsWith("control_") || key.getKey().startsWith("character_gear")));
}
private synchronized void loaded(Attempt attempt, Loaded loaded, Throwable failure) {
@@ -508,6 +526,7 @@ public final class EquipmentSessions implements AutoCloseable {
attempt.player.getInventory().setItem(slot, items[slot]);
if (interrupted(attempt)) return;
}
if(gear!=null)gear.install(attempt.player.getInventory(),record,attempt.owner,attempt.session);
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
if (vitals != null) {
attempt.vitalsStarted = true;
@@ -598,16 +617,24 @@ public final class EquipmentSessions implements AutoCloseable {
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
// Submit first: any subsequent native getter/setter/notice can reenter disable, which
// closes CharacterStorage. No dependent character work is submitted after this point.
persist(() -> characters.recordDeath(attempt.character), (dead, failure) -> {
boolean loseCoins = pvpDefeat.test(owner);
persist(() -> characters.recordDeath(attempt.character, loseCoins), (dead, failure) -> {
if (failure != null) { diagnostic(attempt, failure); conversionUncertain(attempt, "Death save failed or is uncertain"); return; }
if (dead.isEmpty()) { conversionUncertain(attempt, "Character was missing or already dead; no new Tail authorized"); return; }
attempt.deathResolved = true;
if (closed) { conversionUncertain(attempt, "Shutdown before Tail conversion"); return; }
try {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) { deliverTail(attempt, dead.orElseThrow()); }
});
} catch (RuntimeException error) { diagnostic(attempt, error); conversionUncertain(attempt, "Tail callback rejected"); }
if (closed) { conversionUncertain(attempt, "Shutdown before party defeat and Tail conversion"); return; }
java.util.concurrent.CompletionStage<?> partyMutation;
try { partyMutation = partyDefeat.apply(attempt.character); }
catch (RuntimeException error) { diagnostic(attempt,error); conversionUncertain(attempt,"Party defeat mutation rejected"); return; }
partyMutation.whenComplete((ignored,partyFailure) -> {
if (partyFailure != null) { diagnostic(attempt,partyFailure); conversionUncertain(attempt,"Party defeat mutation failed"); return; }
if (closed) { conversionUncertain(attempt,"Shutdown before Tail conversion"); return; }
try {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) { deliverTail(attempt, dead.orElseThrow()); }
});
} catch (RuntimeException error) { diagnostic(attempt, error); conversionUncertain(attempt, "Tail callback rejected"); }
});
});
try {
attempt.deathReference = copy(attempt.player.getInventory().getItemInOffHand());
@@ -771,7 +798,7 @@ public final class EquipmentSessions implements AutoCloseable {
}
mark(attempt, EquipmentSessionMarker.Phase.RESTORING);
var result = equipment.restore(attempt.player.getInventory(), attempt.owner, attempt.session,
item -> controls.owned(item, attempt.owner, attempt.session),
item -> controls.owned(item, attempt.owner, attempt.session)||(gear!=null&&gear.owned(item,attempt.owner,attempt.session)),
attempt.player.getItemOnCursor(), attempt.player::setItemOnCursor);
// Returned refusals are read-only. Exceptions must retain the ambiguous RESTORING phase.
mark(attempt, result == EquipmentStorage.RestoreResult.RESTORED
@@ -0,0 +1,68 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Consumer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
import org.bukkit.entity.Player;
/** Ordinary-player command for one durable account-owned routine effect-feedback switch. */
public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable {
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private boolean closed;
public FantasyFeedbackCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Fantasy feedback is a player-account preference.");
return true;
}
if (args.length == 0) {
player.sendMessage("Fantasy feedback is " + state(preferences.enabled(player.getUniqueId())) + ".");
return true;
}
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) return false;
boolean enabled = args[0].equals("on");
var account = player.getUniqueId();
preferences.set(account, enabled).whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (failure != null) {
if (player.isOnline()) player.sendMessage(
"Fantasy feedback preference could not be saved; it remains "
+ state(preferences.enabled(account)) + ".");
return;
}
boolean published = preferences.publish(saved);
if (player.isOnline()) player.sendMessage("Fantasy feedback is "
+ (published ? "now " : "already ") + state(preferences.enabled(account)) + ".");
});
} catch (RuntimeException rejected) { report(rejected); }
});
return true;
}
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (!(sender instanceof Player) || args.length != 1) return List.of();
String prefix = args[0].toLowerCase(Locale.ROOT);
return List.of("off", "on").stream().filter(value -> value.startsWith(prefix)).toList();
}
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
@Override public void close() { closed = true; }
}
@@ -1,6 +1,8 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.presentation.CombatFeedback;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigDecimal;
@@ -38,7 +40,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(30), Duration.ofSeconds(10), BigInteger.ONE);
return new Settings(Duration.ofSeconds(10), Duration.ofSeconds(10), BigInteger.ONE);
}
}
@@ -61,6 +63,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private final Consumer<Failure> failures;
private final CombatFeedback feedback;
private final CombatSources sources;
private java.util.function.Function<CharacterRecord, BigInteger> bonusHealing = ignored -> BigInteger.ZERO;
private final Map<UUID, State> states = new HashMap<>();
@@ -73,11 +76,17 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
public OutOfCombatRecovery(Settings settings, CharacterStorage characters, EquipmentSessions sessions, Clock clock,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
this(settings, characters, sessions, clock, nextTick, healingEvents, nativeLimit, failures, null);
}
public OutOfCombatRecovery(Settings settings, CharacterStorage characters, EquipmentSessions sessions, Clock clock,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures, CombatFeedback feedback) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.clock = Objects.requireNonNull(clock);
this.nextTick = Objects.requireNonNull(nextTick); this.healingEvents = Objects.requireNonNull(healingEvents);
this.nativeLimit = Objects.requireNonNull(nativeLimit); this.failures = Objects.requireNonNull(failures);
sources = new CombatSources(sessions);
this.feedback = feedback; sources = new CombatSources(sessions);
}
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
@@ -225,6 +234,8 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
double publish = Math.min(maximum, persisted.health().doubleValue());
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) return;
player.setHealth(publish);
if (feedback != null) feedback.report("Out-of-Combat Recovery", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
private void remove(UUID owner) {
@@ -0,0 +1,85 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.GearSlot;
import games.dmg.fantasystick.persistence.GearTier;
import java.math.BigInteger;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
/** One acknowledged server-wide generation shared by every Fantasy Armorer. */
public record ArmorCatalog(LocalDate day, UUID revision, Instant refreshedAt, RefreshReason reason,
Map<ArmorProduct, Offer> products, Map<UUID, Transaction> transactions) {
public enum RefreshReason { STARTUP, SCHEDULED, ADMINISTRATIVE }
public enum TransactionState { RESERVED, CONSUMED, CANCELLED }
public record Offer(BigInteger price, int remaining) {
public Offer {
Objects.requireNonNull(price);
if (price.signum() <= 0 || remaining < 0) throw new IllegalArgumentException("Invalid catalog offer");
}
}
public record Transaction(ArmorProduct product, TransactionState state) {
public Transaction { Objects.requireNonNull(product); Objects.requireNonNull(state); }
}
public ArmorCatalog(LocalDate day, UUID revision, Instant refreshedAt, RefreshReason reason,
Map<ArmorProduct, Offer> products) {
this(day, revision, refreshedAt, reason, products, Map.of());
}
public ArmorCatalog {
Objects.requireNonNull(day);
Objects.requireNonNull(revision);
Objects.requireNonNull(refreshedAt);
Objects.requireNonNull(reason);
products = Map.copyOf(products);
transactions = Map.copyOf(transactions);
var expected = new HashSet<ArmorProduct>();
for (var slot : GearSlot.values()) for (var tier : GearTier.values())
if (tier != GearTier.NONE) expected.add(new ArmorProduct(slot,tier));
if (!products.keySet().equals(expected)) throw new IllegalArgumentException("Catalog must contain all 18 products");
}
public Offer offer(ArmorProduct product) {
var offer = products.get(Objects.requireNonNull(product));
if (offer == null) throw new IllegalArgumentException("Product is absent from catalog");
return offer;
}
public ArmorCatalog reserve(UUID operation, ArmorProduct product) {
Objects.requireNonNull(operation);
var existing=transactions.get(operation);
if(existing!=null) {
if(!existing.product().equals(product))throw new IllegalArgumentException("Operation identity collision");
return this;
}
var offer=offer(product);
if(offer.remaining()==0)throw new IllegalStateException("Product is sold out");
var nextProducts=new LinkedHashMap<>(products);
nextProducts.put(product,new Offer(offer.price(),offer.remaining()-1));
var nextTransactions=new LinkedHashMap<>(transactions);
nextTransactions.put(operation,new Transaction(product,TransactionState.RESERVED));
return new ArmorCatalog(day,revision,refreshedAt,reason,nextProducts,nextTransactions);
}
public ArmorCatalog settle(UUID operation, boolean consumed) {
var transaction=transactions.get(Objects.requireNonNull(operation));
if(transaction==null)return this;
if(transaction.state()!=TransactionState.RESERVED)return this;
var nextProducts=new LinkedHashMap<>(products);
if(!consumed) {
var offer=offer(transaction.product());
nextProducts.put(transaction.product(),new Offer(offer.price(),Math.addExact(offer.remaining(),1)));
}
var nextTransactions=new LinkedHashMap<>(transactions);
nextTransactions.put(operation,new Transaction(transaction.product(),
consumed?TransactionState.CONSUMED:TransactionState.CANCELLED));
return new ArmorCatalog(day,revision,refreshedAt,reason,nextProducts,nextTransactions);
}
public ArmorCatalog prune(UUID operation) {
if(!transactions.containsKey(operation))return this;
var next=new LinkedHashMap<>(transactions); next.remove(operation);
return new ArmorCatalog(day,revision,refreshedAt,reason,products,next);
}
}
@@ -0,0 +1,45 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.GearSlot;
import games.dmg.fantasystick.persistence.GearTier;
import java.math.BigInteger;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
/** Exact integer catalog generation without floating-point price drift. */
public final class ArmorCatalogGenerator {
@FunctionalInterface public interface InclusiveRoll { int roll(int minimum, int maximum); }
private static final BigInteger BASIS_POINTS = BigInteger.valueOf(10_000);
private ArmorCatalogGenerator() { }
public static ArmorCatalog generate(LocalDate day, Instant refreshedAt, ArmorCatalog.RefreshReason reason,
ArmorMarketRules rules, InclusiveRoll random) {
Objects.requireNonNull(rules);
Objects.requireNonNull(random);
var products = new LinkedHashMap<ArmorProduct, ArmorCatalog.Offer>();
for (var slot : GearSlot.values()) {
for (var tier : GearTier.values()) {
if (tier == GearTier.NONE) continue;
int spread = Math.multiplyExact(100, rules.variancePercent());
int offset = checked(random, -spread, spread);
var numerator = rules.basePrices().get(tier).multiply(BigInteger.valueOf(10_000L + offset));
var division = numerator.divideAndRemainder(BASIS_POINTS);
var price = division[0].add(division[1].signum() == 0 ? BigInteger.ZERO : BigInteger.ONE).max(BigInteger.ONE);
var range = rules.stock().get(tier);
int remaining = checked(random, range.minimum(), range.maximum());
products.put(new ArmorProduct(slot, tier), new ArmorCatalog.Offer(price, remaining));
}
}
return new ArmorCatalog(day, UUID.randomUUID(), refreshedAt, reason, products);
}
private static int checked(InclusiveRoll random, int minimum, int maximum) {
int result = random.roll(minimum, maximum);
if (result < minimum || result > maximum) throw new IllegalArgumentException("Random roll outside requested range");
return result;
}
}
@@ -0,0 +1,163 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.*;
import java.io.IOException;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
import java.util.function.Supplier;
/** Serialized durable authority for the one shared daily armor catalog. */
public final class ArmorMarket implements AutoCloseable {
public enum PurchaseStatus { APPLIED, SOLD_OUT, STALE, INSUFFICIENT, NOT_UPGRADE, UNAVAILABLE, FAILED }
public record PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog) {
public PurchaseOutcome { Objects.requireNonNull(status); Objects.requireNonNull(record); Objects.requireNonNull(catalog); }
}
private final ArmorCatalogRepository catalogs;
private final ArmorPurchaseOperationStore operations;
private final CharacterStorage characters;
private final Supplier<ArmorMarketRules> rules;
private final Clock clock;
private final ArmorCatalogGenerator.InclusiveRoll random;
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armor-market"));
private final CompletionStage<Void> readiness;
private volatile ArmorCatalog snapshot;
private volatile boolean initialized;
private volatile Consumer<ArmorCatalog> listener=ignored->{};
private boolean closed,operationsClosing;
private int pending;
private final CompletableFuture<Void> drained=new CompletableFuture<>();
public ArmorMarket(ArmorCatalogRepository catalogs, ArmorPurchaseOperationStore operations,
CharacterStorage characters, Supplier<ArmorMarketRules> rules, Clock clock,
ArmorCatalogGenerator.InclusiveRoll random) {
this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);
this.characters=Objects.requireNonNull(characters);this.rules=Objects.requireNonNull(rules);
this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);
readiness=submit(()->{initialize();return null;});
}
public void attachListener(Consumer<ArmorCatalog> listener){this.listener=Objects.requireNonNull(listener);}
public Optional<ArmorCatalog> snapshot(){return Optional.ofNullable(snapshot);}
public Instant now(){return clock.instant();}
public CompletionStage<Void> ready(){return readiness;}
public CompletionStage<ArmorCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(ArmorCatalog.RefreshReason.SCHEDULED)));}
public CompletionStage<ArmorCatalog> restock(){return readiness.thenCompose(ignored->submit(()->{
var current=ensureCurrent(ArmorCatalog.RefreshReason.SCHEDULED);
if(!current.transactions().isEmpty())throw new IOException("Armor purchases are unresolved");
var generated=ArmorCatalogGenerator.generate(LocalDate.now(clock.withZone(ZoneOffset.UTC)),
clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),
ArmorCatalog.RefreshReason.ADMINISTRATIVE,rules.get(),random);
persist(generated,true);return generated;
}));}
public static Instant nextScheduledRestock(Instant now){return Objects.requireNonNull(now).atZone(ZoneOffset.UTC)
.toLocalDate().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();}
public CompletionStage<PurchaseOutcome> purchase(UUID character, java.math.BigInteger expectedCharacterRevision,
UUID expectedCatalogRevision, ArmorProduct product) {
Objects.requireNonNull(character);Objects.requireNonNull(expectedCharacterRevision);
Objects.requireNonNull(expectedCatalogRevision);Objects.requireNonNull(product);
return readiness.thenCompose(ignored->submit(()->purchaseBlocking(character,expectedCharacterRevision,
expectedCatalogRevision,product)));
}
private void initialize() throws Exception {
snapshot=catalogs.load().orElse(null);
var plans=operations.load().toCompletableFuture().join();
if(snapshot==null&&!plans.isEmpty())throw new IOException("Armor catalog is missing for pending purchases");
for(var plan:plans)execute(plan);
if(snapshot!=null&&!snapshot.transactions().isEmpty()) {
var next=snapshot;
for(var entry:snapshot.transactions().entrySet()) {
if(entry.getValue().state()==ArmorCatalog.TransactionState.RESERVED)
throw new IOException("Orphan reserved armor purchase "+entry.getKey());
next=next.prune(entry.getKey());
}
persist(next,false);
}
ensureCurrent(ArmorCatalog.RefreshReason.STARTUP);
initialized=true;
}
private PurchaseOutcome purchaseBlocking(UUID character,java.math.BigInteger expectedCharacterRevision,
UUID expectedCatalogRevision,ArmorProduct product)throws Exception {
var catalog=ensureCurrent(ArmorCatalog.RefreshReason.SCHEDULED);
if(!catalog.transactions().isEmpty())return new PurchaseOutcome(PurchaseStatus.FAILED,Optional.empty(),catalog);
if(!catalog.revision().equals(expectedCatalogRevision))
return new PurchaseOutcome(PurchaseStatus.STALE,Optional.empty(),catalog);
var offer=catalog.offer(product);
if(offer.remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);
var plan=new ArmorPurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,
catalog.revision(),product,offer.price());
operations.prepare(plan).toCompletableFuture().join();
return execute(plan);
}
private PurchaseOutcome execute(ArmorPurchaseOperationStore.Plan plan)throws Exception {
var catalog=Objects.requireNonNull(snapshot,"Armor catalog unavailable");
if(!catalog.revision().equals(plan.catalogRevision()))
throw new IOException("Pending armor purchase belongs to another catalog revision");
var transaction=catalog.transactions().get(plan.id());
if(transaction==null) {
try { catalog=catalog.reserve(plan.id(),plan.product()); }
catch(IllegalStateException soldOut) {
operations.complete(plan.id()).toCompletableFuture().join();
return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);
}
persist(catalog,false);
transaction=catalog.transactions().get(plan.id());
}
if(!transaction.product().equals(plan.product()))throw new IOException("Armor reservation product mismatch");
if(transaction.state()!=ArmorCatalog.TransactionState.RESERVED) {
operations.complete(plan.id()).toCompletableFuture().join();
var pruned=catalog.prune(plan.id());persist(pruned,false);
var record=characters.find(plan.character()).join();
return new PurchaseOutcome(transaction.state()==ArmorCatalog.TransactionState.CONSUMED
?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,record,pruned);
}
CharacterStorage.GearPurchase purchase;
try {
purchase=characters.purchaseGear(plan.character(),plan.expectedCharacterRevision(),plan.id(),
plan.product().slot(),plan.product().tier(),plan.price()).join();
} catch(CompletionException failure) { throw failure; }
boolean consumed=purchase.status()==CharacterStorage.GearPurchaseStatus.APPLIED||
purchase.status()==CharacterStorage.GearPurchaseStatus.DUPLICATE;
catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);
operations.complete(plan.id()).toCompletableFuture().join();
catalog=catalog.prune(plan.id());persist(catalog,false);
var status=switch(purchase.status()) {
case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;
case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;
case NOT_UPGRADE->PurchaseStatus.NOT_UPGRADE;
case STALE->PurchaseStatus.STALE;
case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;
};
return new PurchaseOutcome(status,purchase.record(),catalog);
}
private ArmorCatalog ensureCurrent(ArmorCatalog.RefreshReason reason)throws IOException {
LocalDate today=LocalDate.now(clock.withZone(ZoneOffset.UTC));
var current=snapshot;
if(current!=null&&current.day().equals(today))return current;
if(current!=null&&!current.transactions().isEmpty())throw new IOException("Armor purchases are unresolved at catalog rollover");
var generated=ArmorCatalogGenerator.generate(today,
clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);
persist(generated,true);
return generated;
}
private void persist(ArmorCatalog catalog,boolean notify)throws IOException {
catalogs.save(catalog);snapshot=catalog;if(notify)publish(catalog);
}
private void publish(ArmorCatalog catalog){if(!initialized)return;try{listener.accept(catalog);}catch(RuntimeException ignored){}}
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(
new IOException("Armor market closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}
catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
private synchronized void finished(){pending--;if(closed&&pending==0)closeOperations();}
private void closeOperations(){if(operationsClosing)return;operationsClosing=true;operations.close();
operations.drained().whenComplete((ignored,failure)->{if(failure==null)drained.complete(null);else drained.completeExceptionally(failure);});}
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)closeOperations();}
}
@@ -0,0 +1,34 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.GearTier;
import java.math.BigInteger;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
/** Validated configuration captured for one future catalog generation. */
public record ArmorMarketRules(Map<GearTier, BigInteger> basePrices, int variancePercent,
Map<GearTier, StockRange> stock) {
public record StockRange(int minimum, int maximum) {
public StockRange {
if (minimum <= 0 || maximum < minimum) throw new IllegalArgumentException("Invalid stock range");
}
}
public ArmorMarketRules {
Objects.requireNonNull(basePrices);
Objects.requireNonNull(stock);
if (variancePercent < 0 || variancePercent > 100) throw new IllegalArgumentException("Invalid variance");
var prices = new EnumMap<GearTier, BigInteger>(GearTier.class);
var ranges = new EnumMap<GearTier, StockRange>(GearTier.class);
for (var tier : GearTier.values()) {
if (tier == GearTier.NONE) continue;
var price = Objects.requireNonNull(basePrices.get(tier), "Missing base price");
if (price.signum() < 0) throw new IllegalArgumentException("Negative base price");
prices.put(tier, price);
ranges.put(tier, Objects.requireNonNull(stock.get(tier), "Missing stock range"));
}
basePrices = Map.copyOf(prices);
stock = Map.copyOf(ranges);
}
}
@@ -0,0 +1,14 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.GearSlot;
import games.dmg.fantasystick.persistence.GearTier;
import java.util.Objects;
/** One independently priced and stocked armor slot/tier product. */
public record ArmorProduct(GearSlot slot, GearTier tier) {
public ArmorProduct {
Objects.requireNonNull(slot);
Objects.requireNonNull(tier);
if (tier == GearTier.NONE) throw new IllegalArgumentException("None is not purchasable");
}
}
@@ -0,0 +1,88 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.time.Instant;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Active-character Armorer boundary backed by the one recoverable shared market. */
public final class ArmorShop {
public enum Status { APPLIED, INVALID_SESSION, SOLD_OUT, INSUFFICIENT, NOT_UPGRADE, STALE, FAILED }
public record Outcome(Status status,Optional<CharacterRecord> record){}
public record View(CharacterRecord record,UUID session,ArmorCatalog catalog){}
private record Context(EquipmentSessions.Active active,CharacterRecord record,FantasyArmorer shop){}
private final FantasyArmorerDirectory shops;
private final EquipmentSessions sessions;
private final ArmorMarket market;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
public ArmorShop(FantasyArmorerDirectory shops,EquipmentSessions sessions,ArmorMarket market,
Consumer<Runnable> nextTick,Consumer<Throwable> failures){
this.shops=Objects.requireNonNull(shops);this.sessions=Objects.requireNonNull(sessions);
this.market=Objects.requireNonNull(market);this.nextTick=Objects.requireNonNull(nextTick);
this.failures=Objects.requireNonNull(failures);
}
public Optional<CharacterRecord> active(Player player,UUID shop){return context(player,shop).map(Context::record);}
public Optional<UUID> activeSession(Player player,UUID shop){return context(player,shop).map(value->value.active().session());}
public Instant now(){return market.now();}
public void view(Player player,UUID shopId,Consumer<Optional<View>> result){
var captured=context(player,shopId);
if(captured.isEmpty()){result.accept(Optional.empty());return;}
market.current().whenComplete((catalog,failure)->{
if(failure!=null){report(failure);publish(()->result.accept(Optional.empty()));return;}
publish(()->{
var current=context(player,shopId);
if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.orElseThrow().active().session()))
result.accept(Optional.empty());
else result.accept(Optional.of(new View(current.orElseThrow().record(),current.orElseThrow().active().session(),catalog)));
});
});
}
public void buy(Player player,UUID shopId,UUID catalogRevision,ArmorProduct product,Consumer<Outcome> result){
var lease=shops.acquire(shopId);
if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}
var held=lease.orElseThrow();
var context=context(player,shopId);
if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}
var captured=context.orElseThrow();
market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{
if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}
publishHeld(held,()->{
var current=context(player,shopId);
if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){
result.accept(new Outcome(Status.STALE,purchase.record()));return;
}
Status status=switch(purchase.status()){
case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;
case NOT_UPGRADE->Status.NOT_UPGRADE;case STALE->Status.STALE;
case UNAVAILABLE,FAILED->Status.FAILED;
};
if(purchase.status()==ArmorMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent())
sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());
result.accept(new Outcome(status,purchase.record()));
});
});
}
private Optional<Context> context(Player player,UUID shopId){
var shop=shops.find(shopId).orElse(null);
var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);
if(shop==null||record==null)return Optional.empty();
var location=player.getLocation();
if(location.getWorld()==null||!location.getWorld().getUID().equals(shop.world()))return Optional.empty();
double x=location.getX()-shop.x(),y=location.getY()-shop.y(),z=location.getZ()-shop.z();
return x*x+y*y+z*z<=36?Optional.of(new Context(active,record,shop)):Optional.empty();
}
private void publishHeld(FantasyArmorerDirectory.Lease lease,Runnable action){try{nextTick.accept(()->{try{action.run();}finally{lease.close();}});}
catch(RuntimeException failure){lease.close();report(failure);}}
private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
}
@@ -0,0 +1,113 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.*;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.*;
import org.bukkit.*;
import org.bukkit.entity.Player;
import org.bukkit.event.*;
import org.bukkit.event.inventory.*;
import org.bukkit.inventory.*;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
/** Bounded shared-catalog armor shop; every click revalidates generation, character, session and shop. */
public final class ArmorShopMenus implements Listener,AutoCloseable {
private record Holder(UUID shop,UUID character,UUID session,UUID catalog)implements InventoryHolder{
public Inventory getInventory(){return null;}}
private final ArmorShop service;
private final org.bukkit.Server server;
private final NamespacedKey slotKey,tierKey;
private boolean closed;
public ArmorShopMenus(Plugin plugin,ArmorShop service){this.service=Objects.requireNonNull(service);server=plugin.getServer();
slotKey=new NamespacedKey(plugin,"armor_shop_slot");tierKey=new NamespacedKey(plugin,"armor_shop_tier");}
public void open(Player player,UUID shop){
service.view(player,shop,view->{
if(closed||!player.isOnline())return;
if(view.isEmpty()){
player.sendMessage("You must be actively playing a living fantasy character beside this Fantasy Armorer; the market must also be available.");return;
}
var current=view.orElseThrow();
var inventory=server.createInventory(new Holder(shop,current.record().id(),current.session(),current.catalog().revision()),
54,net.kyori.adventure.text.Component.text("Fantasy Armorer"));
int column=0;
for(var slot:GearSlot.values()){
int row=0;
for(var tier:GearTier.values()){
if(tier==GearTier.NONE)continue;
var product=new ArmorProduct(slot,tier);
inventory.setItem(row*9+column,item(product,current.catalog().offer(product)));
row++;
}
column+=3;
}
inventory.setItem(8,schedule(current.catalog(),service.now()));
player.openInventory(inventory);
});
}
private ItemStack item(ArmorProduct product,ArmorCatalog.Offer offer){
var tier=product.tier();var slot=product.slot();
var item=new ItemStack(Material.valueOf((tier==GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){
case CHESTPLATE->"CHESTPLATE";case LEGGINGS->"LEGGINGS";case BOOTS->"BOOTS";}));
var meta=item.getItemMeta();
meta.displayName(net.kyori.adventure.text.Component.text("Buy "+title(tier.name())+" "+title(slot.name())+
" — "+offer.price()+" coins"));
meta.lore(List.of(net.kyori.adventure.text.Component.text("Stock: "+offer.remaining())));
meta.getPersistentDataContainer().set(slotKey,PersistentDataType.STRING,slot.name());
meta.getPersistentDataContainer().set(tierKey,PersistentDataType.STRING,tier.name());
item.setItemMeta(meta);return item;
}
private static ItemStack schedule(ArmorCatalog catalog,Instant now){
var next=catalog.day().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
long seconds=Math.max(0,Duration.between(now,next).toSeconds());
long hours=seconds/3600,minutes=(seconds%3600)/60;
var item=new ItemStack(Material.CLOCK);var meta=item.getItemMeta();
meta.displayName(net.kyori.adventure.text.Component.text("Restock in: "+hours+"h "+minutes+"m"));
item.setItemMeta(meta);return item;
}
public void refreshOpen(){
if(closed)return;
for(var player:server.getOnlinePlayers()){
var view=player.getOpenInventory();
if(view==null)continue;
var inventory=view.getTopInventory();
if(inventory!=null&&inventory.getHolder() instanceof Holder holder)open(player,holder.shop());
}
}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
public void click(InventoryClickEvent event){
if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;
event.setCancelled(true);
if(closed||!service.active(player,holder.shop()).map(value->value.id().equals(holder.character())).orElse(false)||
!service.activeSession(player,holder.shop()).map(holder.session()::equals).orElse(false)){
player.sendMessage("Armor purchase refused: the character, session or shop changed.");return;
}
var item=event.getCurrentItem();if(item==null)return;
var data=item.getItemMeta().getPersistentDataContainer();
try{
var product=new ArmorProduct(GearSlot.valueOf(data.get(slotKey,PersistentDataType.STRING)),
GearTier.valueOf(data.get(tierKey,PersistentDataType.STRING)));
service.buy(player,holder.shop(),holder.catalog(),product,outcome->{
if(outcome.status()==ArmorShop.Status.APPLIED)player.sendMessage("Purchased "+title(product.tier().name())+" "+title(product.slot().name())+".");
else{
player.sendMessage("Armor purchase refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");
if(outcome.status()==ArmorShop.Status.STALE||outcome.status()==ArmorShop.Status.SOLD_OUT)open(player,holder.shop());
}
});
}catch(RuntimeException ignored){ }
}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
public void drag(InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
private static String title(String value){String lower=value.toLowerCase(Locale.ROOT).replace('_',' ');
return Character.toUpperCase(lower.charAt(0))+lower.substring(1);}
@Override public void close(){closed=true;}
}
@@ -0,0 +1,4 @@
package games.dmg.fantasystick.shop;
import java.util.*;
/** Durable desired location for one administrator-managed Fantasy Armorer. */
public record FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){public FantasyArmorer{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(world);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(codePoint->{int type=Character.getType(codePoint);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;})||!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid Fantasy Armorer");}}
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more