Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a85eea769 | ||
|
|
6d0d695052 | ||
|
|
710a29a43c | ||
|
|
d3f58f4223 | ||
|
|
87024bfd24 | ||
|
|
d8bbe78a08 | ||
|
|
37be6b13fa | ||
|
|
1dcc7cfae8 | ||
|
|
e16570cb35 | ||
|
|
be8870502f | ||
|
|
8b5461753c | ||
|
|
91412fa1ae | ||
|
|
61bc67da3b | ||
|
|
1cc1012ecd | ||
|
|
396baf473f | ||
|
|
1468db0533 | ||
|
|
f9ab5b6e46 | ||
|
|
0042f539a5 | ||
|
|
9d6b945f82 | ||
|
|
5c5f774dea | ||
|
|
0536d4c717 | ||
|
|
079555900c | ||
|
|
1f3a1fe396 | ||
|
|
139cc4519b | ||
|
|
184d5520b2 | ||
|
|
16087bdfec |
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,11 +116,24 @@ 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.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
|
||||
private games.dmg.fantasystick.session.OutOfCombatRecovery outOfCombatRecovery;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
|
||||
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);
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -131,15 +162,33 @@ 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 recoveryConfiguration = outOfCombatRecoveryConfiguration();
|
||||
var recoveryInitial = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(recoveryConfiguration);
|
||||
var quickerRegenerationInitial = games.dmg.fantasystick.persistence.QuickerRegenerationSettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT));
|
||||
var quickerRegenerationIIInitial = games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY,
|
||||
games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT));
|
||||
var healthStealInitial = games.dmg.fantasystick.persistence.HealthStealSettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.HealthStealSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT));
|
||||
var sheetPackDefaults = games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults();
|
||||
sheetResourcePackSettings = new games.dmg.fantasystick.persistence.SheetResourcePackSettings(getDataFolder().toPath(),
|
||||
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 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);
|
||||
@@ -177,14 +226,32 @@ 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);
|
||||
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.
|
||||
@@ -216,23 +283,57 @@ 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()));
|
||||
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
|
||||
} catch (java.io.IOException invalidManagedState) {
|
||||
throw new java.io.UncheckedIOException("Persistent party, shop or training state could not be loaded", invalidManagedState);
|
||||
}
|
||||
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);
|
||||
@@ -326,6 +427,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);
|
||||
@@ -335,7 +437,38 @@ 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);
|
||||
quickerRegenerationIISettings = new games.dmg.fantasystick.persistence.QuickerRegenerationIISettings(
|
||||
getDataFolder().toPath(), quickerRegenerationIIInitial.bonus().toString());
|
||||
quickerRegenerationII = new games.dmg.fantasystick.passive.QuickerRegenerationII(quickerRegenerationIIInitial);
|
||||
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
|
||||
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()),
|
||||
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());
|
||||
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()));
|
||||
outOfCombatRecovery.attachBonusHealing(record -> {
|
||||
if (!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)) {
|
||||
return java.math.BigInteger.ZERO;
|
||||
}
|
||||
var bonus = quickerRegeneration.bonus();
|
||||
return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)
|
||||
? bonus.add(quickerRegenerationII.bonus()) : bonus;
|
||||
});
|
||||
games.dmg.fantasystick.ability.AbilityScheduler abilityScheduler = (delay, action) -> {
|
||||
long ticks = Math.max(1, (delay.toNanos() + 49_999_999L) / 50_000_000L);
|
||||
var task = getServer().getScheduler().runTaskLater(this, action, ticks);
|
||||
@@ -346,7 +479,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);
|
||||
@@ -356,8 +489,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
String name = localPresentation.get().name(id);
|
||||
var player = getServer().getPlayer(id);
|
||||
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
|
||||
}, abilityRegistry, abilityEngine);
|
||||
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
|
||||
}, abilityRegistry, abilityEngine, passiveRegistry, passiveEngine);
|
||||
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);
|
||||
@@ -365,6 +499,24 @@ 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);
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
|| armorerController.tagged(entity));
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings,
|
||||
@@ -372,6 +524,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);
|
||||
@@ -389,8 +542,18 @@ 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.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
|
||||
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
|
||||
admin.attachQuickerRegenerationII(quickerRegenerationIISettings, quickerRegenerationII, passiveRegistry);
|
||||
admin.attachHealthSteal(healthStealSettings, healthSteal, passiveRegistry);
|
||||
admin.attachCharacterIssuer(new games.dmg.fantasystick.session.AdminCharacterIssuer(characters,
|
||||
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
|
||||
"Admin character issue " + problem.operation() + " for player " + problem.player()
|
||||
@@ -398,6 +561,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);
|
||||
@@ -405,7 +572,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);
|
||||
partyRegistrarController.install();
|
||||
trainingDummyController.install();
|
||||
armorerController.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);
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(fireball, this);
|
||||
@@ -431,6 +612,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
|
||||
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);
|
||||
@@ -459,11 +642,25 @@ 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 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;
|
||||
@@ -479,6 +676,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;
|
||||
@@ -515,6 +713,9 @@ 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 closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
|
||||
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
|
||||
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
|
||||
@@ -531,11 +732,22 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
|
||||
var closingOutOfCombatRecovery = outOfCombatRecovery; outOfCombatRecovery = null;
|
||||
var closingOutOfCombatRecoverySettings = outOfCombatRecoverySettings; outOfCombatRecoverySettings = null;
|
||||
var closingQuickerRegenerationSettings = quickerRegenerationSettings; quickerRegenerationSettings = null;
|
||||
var closingQuickerRegenerationIISettings = quickerRegenerationIISettings; quickerRegenerationIISettings = null;
|
||||
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);
|
||||
@@ -565,12 +777,35 @@ 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 (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);
|
||||
if (closingQuickerRegenerationSettings != null) cleanup("Quicker Regeneration I configuration", closingQuickerRegenerationSettings::close);
|
||||
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(),
|
||||
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)
|
||||
@@ -616,7 +851,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingSheetResourcePackSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSheetResourcePackSettings.drained().toCompletableFuture(),
|
||||
closingOutOfCombatRecoverySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingOutOfCombatRecoverySettings.drained().toCompletableFuture());
|
||||
: closingOutOfCombatRecoverySettings.drained().toCompletableFuture(),
|
||||
closingQuickerRegenerationSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickerRegenerationSettings.drained().toCompletableFuture(),
|
||||
closingQuickerRegenerationIISettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickerRegenerationIISettings.drained().toCompletableFuture(),
|
||||
closingHealthStealSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingHealthStealSettings.drained().toCompletableFuture());
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingAdminRevival != null) cleanup("admin revival", closingAdminRevival::close);
|
||||
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
|
||||
@@ -637,8 +878,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
|
||||
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
|
||||
if (closingOutOfCombatRecovery != null) cleanup("out-of-combat recovery", closingOutOfCombatRecovery::close);
|
||||
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);
|
||||
@@ -648,7 +906,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingChat);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingPartyRegistrarController);
|
||||
unregister(closingTrainingDummyController);
|
||||
unregister(closingPartyRegistrarMenus);
|
||||
unregister(closingArmorerController);unregister(closingArmorShopMenus);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingHealthSteal);
|
||||
unregister(closingFireball);
|
||||
unregister(closingFrostShard);
|
||||
unregister(closingLongbow);
|
||||
@@ -718,18 +981,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -912,7 +1171,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&÷d[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&÷d[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);
|
||||
@@ -269,7 +269,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
private void deliver(View view, CharacterRecord dead, RevivalStore.Reservation reservation) {
|
||||
try {
|
||||
var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(),
|
||||
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.cooldowns(), dead.restrictedKills(),
|
||||
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.passives(), dead.cooldowns(), dead.restrictedKills(),
|
||||
false, dead.deathId(), dead.diedAt());
|
||||
ItemStack sheet = sheets.create(provisional);
|
||||
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -27,6 +27,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private final Consumer<Failure> failures;
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry abilities;
|
||||
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
|
||||
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
|
||||
@@ -34,15 +36,19 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
|
||||
private static final net.kyori.adventure.key.Key PASSIVE_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlock");
|
||||
private static final net.kyori.adventure.key.Key PASSIVE_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlearn");
|
||||
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
|
||||
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
|
||||
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
|
||||
private record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, PassiveView> passiveViews = new ConcurrentHashMap<>();
|
||||
|
||||
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
@@ -50,7 +56,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
|
||||
this(characters, rules, sessions, nextTick, failures, parties, server, names,
|
||||
new games.dmg.fantasystick.ability.AbilityRegistry(), null);
|
||||
new games.dmg.fantasystick.ability.AbilityRegistry(), null,
|
||||
new games.dmg.fantasystick.passive.PassiveRegistry(), null);
|
||||
}
|
||||
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
@@ -59,6 +66,18 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities,
|
||||
games.dmg.fantasystick.ability.AbilityEngine abilityEngine) {
|
||||
this(characters, rules, sessions, nextTick, failures, parties, server, names, abilities, abilityEngine,
|
||||
new games.dmg.fantasystick.passive.PassiveRegistry(), null);
|
||||
}
|
||||
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities,
|
||||
games.dmg.fantasystick.ability.AbilityEngine abilityEngine,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry passives,
|
||||
games.dmg.fantasystick.passive.PassiveEngine passiveEngine) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
@@ -66,6 +85,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
this.abilities = Objects.requireNonNull(abilities);
|
||||
this.abilityEngine = abilityEngine;
|
||||
this.passives = Objects.requireNonNull(passives);
|
||||
this.passiveEngine = passiveEngine;
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
}
|
||||
|
||||
@@ -79,6 +100,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
passiveViews.remove(owner);
|
||||
Object ticket = new Object();
|
||||
pending.put(owner, ticket);
|
||||
characters.find(input.session().character()).whenComplete((record, failure) -> {
|
||||
@@ -116,31 +138,74 @@ 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();
|
||||
if (!definitions.isEmpty() && end == allOverview.size()) {
|
||||
var passiveDefinitions = passives.definitions().stream()
|
||||
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.passive.PassiveDefinition::name)
|
||||
.thenComparing(games.dmg.fantasystick.passive.PassiveDefinition::id))
|
||||
.toList();
|
||||
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
|
||||
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
|
||||
abilityViews.put(input.session().player(), abilityView);
|
||||
var details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
|
||||
if (!definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
|
||||
var passiveView = new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
|
||||
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
|
||||
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::id,
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::name));
|
||||
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 indexCount = indexPages(definitions, placeholderTargets).size();
|
||||
int passiveCount = passiveSlotPages(record, 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 + indexCount + 1;
|
||||
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 += 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(indexPages(definitions, targets));
|
||||
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
|
||||
pages.addAll(index.pages());
|
||||
pages.addAll(passiveIndex.pages());
|
||||
details.forEach(pages::addAll);
|
||||
passiveDetails.forEach(pages::addAll);
|
||||
}
|
||||
if (allOverview.size() > VOLUME_SIZE) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
@@ -151,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());
|
||||
@@ -172,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();
|
||||
@@ -208,21 +277,102 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static java.util.List<Component> indexPages(
|
||||
private static java.util.List<Component> passiveSlotPages(CharacterRecord record,
|
||||
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
|
||||
var byId = definitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::id, java.util.function.Function.identity()));
|
||||
var builder = new PageBuilder(); builder.text("Passive abilities"); builder.blank();
|
||||
for (int slot = 0; slot < record.passives().size(); slot++) {
|
||||
String id = record.passives().get(slot); var definition = byId.get(id);
|
||||
if (id.isEmpty()) builder.text((slot + 1) + ": No Passive");
|
||||
else if (definition == null) builder.text((slot + 1) + ": " + id);
|
||||
else builder.link((slot + 1) + ": ", definition.name(), targets.get(id));
|
||||
}
|
||||
return builder.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 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 builder = new PageBuilder(); builder.text("Ability index"); builder.blank();
|
||||
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
|
||||
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();
|
||||
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,
|
||||
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()) {
|
||||
String prerequisiteNames = definition.prerequisites().stream().map(id -> names.getOrDefault(id, id)).sorted()
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
requirements = requirements.isEmpty() ? prerequisiteNames : prerequisiteNames + ", " + requirements;
|
||||
}
|
||||
boolean learned = record.passives().contains(definition.id());
|
||||
boolean prerequisites = record.passives().containsAll(definition.prerequisites());
|
||||
boolean stats = definition.requirements().entrySet().stream()
|
||||
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
|
||||
boolean points = record.unspentPoints().compareTo(definition.pointCost()) >= 0;
|
||||
boolean slot = record.passives().contains("");
|
||||
String state = learned ? "Learned" : !prerequisites || !stats ? "Unavailable: requirements"
|
||||
: !points ? "Unavailable: points" : !slot ? "Unavailable: passive slots" : "Available";
|
||||
var builder = new PageBuilder(); builder.text(definition.name()); builder.text(requirements);
|
||||
if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
|
||||
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(", "));
|
||||
@@ -235,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()));
|
||||
@@ -247,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();
|
||||
}
|
||||
|
||||
@@ -308,6 +461,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
|
||||
}
|
||||
|
||||
private static Component passiveButton(String label, net.kyori.adventure.key.Key action, PassiveView view,
|
||||
String passive) {
|
||||
String payload = "\"" + view.nonce() + ":" + passive + "\"";
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
|
||||
}
|
||||
|
||||
private static Component pageButton(String label, View view, int direction) {
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(PAGES,
|
||||
@@ -423,6 +584,50 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
});
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void passiveAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = PASSIVE_UNLOCK.equals(event.getIdentifier());
|
||||
boolean unlearn = PASSIVE_UNLEARN.equals(event.getIdentifier());
|
||||
if (closed || passiveEngine == null || !unlock && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer(); var view = passiveViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player) return;
|
||||
String raw = event.getTag().string(); if (raw.length() > 120) return;
|
||||
var match = ABILITY_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|
||||
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
String passive = match.group(2);
|
||||
var operation = unlock ? passiveEngine.purchase(view.input(), passive, view.revision())
|
||||
: passiveEngine.unlearn(view.input(), passive, view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(view.input(), failure);
|
||||
player.sendMessage("Passive change could not be confirmed; reopen the character after session recovery.");
|
||||
return;
|
||||
}
|
||||
player.sendMessage(passiveMessage(unlock, passive, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String passiveMessage(boolean unlock, String passive,
|
||||
CharacterStorage.PassiveMutationStatus status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> unlock ? "Learned " + passive + " in the first available passive slot."
|
||||
: "Unlearned " + passive + " and refunded 1 point.";
|
||||
case REQUIREMENTS -> "The passive prerequisites or purchased base-stat requirements are not met.";
|
||||
case NO_POINTS -> "This character does not have enough unspent points.";
|
||||
case PASSIVE_SLOTS_FULL -> "All three passive slots are occupied; unlearn a passive before learning another.";
|
||||
case ALREADY_LEARNED -> "This character already knows " + passive + ".";
|
||||
case NOT_LEARNED -> "This character no longer knows " + passive + ".";
|
||||
case REQUIRED_BY_LEARNED -> "Another learned passive requires " + passive + ".";
|
||||
case STALE -> "That character page is stale; reopen it before changing passives.";
|
||||
case UNAVAILABLE -> "That character or passive is unavailable.";
|
||||
};
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, String ability,
|
||||
CharacterStorage.AbilityMutationStatus status) {
|
||||
return switch (status) {
|
||||
@@ -450,10 +655,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
passiveViews.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); partyControls.close();
|
||||
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -38,12 +42,23 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -60,7 +75,11 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
var attempt = new Pending(input, target, now);
|
||||
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));
|
||||
@@ -78,9 +97,11 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
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
|
||||
|| !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,7 @@ public final class SessionControls {
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
meta.setMaxStackSize(1);
|
||||
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>();
|
||||
@@ -52,6 +54,14 @@ public final class TailBooks {
|
||||
section.append(deadline == null ? "\nCooldown: none" : "\nCooldown until:\n" + deadline);
|
||||
pages.addAll(BookPagination.pages(section.toString()));
|
||||
});
|
||||
if (character.passives().stream().allMatch(String::isEmpty)) {
|
||||
pages.addAll(BookPagination.pages("Passives: None"));
|
||||
} else {
|
||||
for (int slot = 0; slot < character.passives().size(); slot++) {
|
||||
String passive = character.passives().get(slot);
|
||||
if (!passive.isEmpty()) pages.addAll(BookPagination.pages("Passive: " + passive + "\nSlot: " + (slot + 1)));
|
||||
}
|
||||
}
|
||||
// Unlearned/unknown saved cooldowns remain readable without pretending they are learned abilities.
|
||||
character.cooldowns().keySet().stream().filter(id -> !character.learned().contains(id)).sorted().forEach(id ->
|
||||
pages.addAll(BookPagination.pages("Saved cooldown\n" + id + "\nUntil:\n" + character.cooldowns().get(id))));
|
||||
@@ -65,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,6 +50,10 @@ 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;
|
||||
@@ -60,31 +65,51 @@ 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 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;
|
||||
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) {
|
||||
this.id = id; this.character = character; this.experience = experience;
|
||||
this.coins = coins; this.restricted = restricted;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -122,20 +147,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);
|
||||
award(event.getEntity(), classify(event.getEntity()), 0, null, BigInteger.ZERO);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
@@ -145,21 +183,60 @@ 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 xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(rewards.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);
|
||||
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 +258,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, rewards, 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 +300,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 +314,27 @@ 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 static CombatRewardRules.Target classify(LivingEntity entity) {
|
||||
EntityType type = entity.getType();
|
||||
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
|
||||
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 +343,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,39 @@
|
||||
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);}
|
||||
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();}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.bukkit.event.entity.EntityRemoveEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
/** Consume-once final-blow attribution and persisted native healing for Health Steal. */
|
||||
public final class HealthSteal implements Listener, AutoCloseable {
|
||||
public static final String ID = "health_steal";
|
||||
private static final int MAX_PENDING_PER_CHARACTER = 64;
|
||||
private static final int MAX_CONSUMED_EVENTS = 1_024;
|
||||
public record Settings(BigInteger healing) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(healing);
|
||||
if (healing.signum() < 0 || healing.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Health Steal healing");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.ONE); }
|
||||
}
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
private record Candidate(LivingEntity target, CombatSources.Origin origin) { }
|
||||
private record Heal(EquipmentSessions.Active active, double amount) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
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<>());
|
||||
private final Set<EntityDeathEvent> consumed = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final ArrayDeque<EntityDeathEvent> consumedOrder = new ArrayDeque<>();
|
||||
private final Map<UUID, ArrayDeque<Heal>> pending = new java.util.HashMap<>();
|
||||
private volatile Settings settings;
|
||||
private boolean closed;
|
||||
|
||||
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); this.feedback = feedback; sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger healing() { return settings.healing(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Health Steal",
|
||||
Map.of(Stat.STR, BigInteger.valueOf(14), Stat.CON, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Immediately restore " + healing() + " health point" + (healing().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " when this active character's accepted damage lands a qualifying final blow.",
|
||||
"Passive mobs qualify; assists, ordinary players and repeated defeat callbacks do not."));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || !(event.getEntity() instanceof LivingEntity target)) return;
|
||||
UUID targetId = target.getUniqueId();
|
||||
if (event.isCancelled() || event.getFinalDamage() <= 0) { lethal.remove(targetId); return; }
|
||||
var origin = sources.source(event);
|
||||
double remaining = target.getHealth() + target.getAbsorptionAmount();
|
||||
if (origin == null || origin.session() == null || !sources.current(origin)
|
||||
|| targetId.equals(origin.player()) || !Double.isFinite(remaining)
|
||||
|| event.getFinalDamage() < remaining) {
|
||||
lethal.remove(targetId); return;
|
||||
}
|
||||
lethal.put(targetId, new Candidate(target, origin));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void beforePlayerDeath(PlayerDeathEvent event) {
|
||||
if (!closed && sessions.active(event.getEntity().getUniqueId()).isPresent()) fantasyDefeats.add(event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void playerDeath(PlayerDeathEvent event) {
|
||||
boolean fantasy = fantasyDefeats.remove(event);
|
||||
if (!closed && fantasy && event.isCancelled()) defeat(event, event.getEntity());
|
||||
else lethal.remove(event.getEntity().getUniqueId());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
if (!closed && !(event instanceof PlayerDeathEvent)) defeat(event, event.getEntity());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void removed(EntityRemoveEvent event) {
|
||||
if (event.getCause() != EntityRemoveEvent.Cause.DEATH) lethal.remove(event.getEntity().getUniqueId());
|
||||
}
|
||||
|
||||
private void defeat(EntityDeathEvent event, LivingEntity target) {
|
||||
if (!consumed.add(event)) return;
|
||||
consumedOrder.addLast(event);
|
||||
while (consumedOrder.size() > MAX_CONSUMED_EVENTS) consumed.remove(consumedOrder.removeFirst());
|
||||
var candidate = lethal.remove(target.getUniqueId());
|
||||
if (candidate == null || candidate.target() != target || !sources.current(candidate.origin())) return;
|
||||
var origin = candidate.origin();
|
||||
var active = sessions.active(origin.player()).orElse(null);
|
||||
if (active == null || !active.session().equals(origin.session()) || !active.character().equals(origin.character())
|
||||
|| !sessions.current(active)) return;
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) return;
|
||||
BigInteger configured = settings.healing();
|
||||
if (configured.signum() == 0) return;
|
||||
var maximumAttribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (maximumAttribute == null) return;
|
||||
double current = player.getHealth();
|
||||
double maximum = Math.min(maximumAttribute.getValue(), record.maximumHealth().doubleValue());
|
||||
double reserved = pending.getOrDefault(active.character(), new ArrayDeque<>()).stream()
|
||||
.mapToDouble(Heal::amount).sum();
|
||||
double amount = configured.bitLength() > 1023 ? maximum - current - reserved : configured.doubleValue();
|
||||
double proposed = Math.min(maximum - current - reserved, amount);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0
|
||||
|| !Double.isFinite(proposed) || proposed <= 0) return;
|
||||
var healing = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(healing);
|
||||
double accepted = Math.min(proposed, Math.min(maximum - current - reserved, healing.getAmount()));
|
||||
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
|
||||
var queue = pending.computeIfAbsent(active.character(), ignored -> new ArrayDeque<>());
|
||||
if (queue.size() >= MAX_PENDING_PER_CHARACTER) return;
|
||||
queue.addLast(new Heal(active, accepted));
|
||||
if (queue.size() == 1) attempt(active.character());
|
||||
}
|
||||
|
||||
private void attempt(UUID character) {
|
||||
var queue = pending.get(character); var operation = queue == null ? null : queue.peekFirst();
|
||||
if (closed || operation == null) return;
|
||||
if (!sessions.current(operation.active())) { finish(character); return; }
|
||||
var record = sessions.currentRecord(operation.active()).orElse(null);
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) { finish(character); return; }
|
||||
double nativeBefore = player.getHealth();
|
||||
BigDecimal target = record.health().add(BigDecimal.valueOf(operation.amount()));
|
||||
BigDecimal limit;
|
||||
try { limit = nativeLimit.get(); }
|
||||
catch (RuntimeException failure) { report(character, failure); finish(character); return; }
|
||||
BigInteger expected = record.revision();
|
||||
characters.saveHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try { nextTick.accept(() -> complete(character, operation, expected, nativeBefore, saved, failure)); }
|
||||
catch (RuntimeException rejected) { report(character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(UUID character, Heal operation, BigInteger expected, double nativeBefore,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (closed || failure != null || saved == null || saved.isEmpty()) {
|
||||
if (failure != null) report(character, failure); return;
|
||||
}
|
||||
var record = saved.orElseThrow();
|
||||
if (!sessions.current(operation.active())
|
||||
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) return;
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
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 (feedback != null) feedback.report("Health Steal", player, AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
|
||||
}
|
||||
} finally { finish(character); }
|
||||
}
|
||||
|
||||
private void finish(UUID character) {
|
||||
var queue = pending.get(character);
|
||||
if (queue == null) return;
|
||||
queue.pollFirst();
|
||||
if (queue.isEmpty()) pending.remove(character); else attempt(character);
|
||||
}
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true; lethal.clear(); fantasyDefeats.clear(); consumed.clear(); consumedOrder.clear(); pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -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,24 @@
|
||||
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.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Immutable internal metadata for one character-owned automatic passive. */
|
||||
public record PassiveDefinition(String id, String name, Map<Stat, BigInteger> requirements,
|
||||
Set<String> prerequisites, BigInteger pointCost, List<String> description) {
|
||||
public PassiveDefinition {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(requirements);
|
||||
Objects.requireNonNull(prerequisites); Objects.requireNonNull(pointCost); Objects.requireNonNull(description);
|
||||
requirements = Map.copyOf(requirements); prerequisites = Set.copyOf(prerequisites); description = List.copyOf(description);
|
||||
if (!id.matches("[a-z][a-z0-9_]{0,63}") || name.isBlank() || !pointCost.equals(BigInteger.ONE)
|
||||
|| requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)
|
||||
|| prerequisites.stream().anyMatch(value -> value.isBlank() || value.equals(id))
|
||||
|| description.isEmpty() || description.stream().anyMatch(String::isBlank)) {
|
||||
throw new IllegalArgumentException("Invalid passive definition");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Save-before-publication passive learning/refund coordinator for native book actions. */
|
||||
public final class PassiveEngine implements AutoCloseable {
|
||||
public record Failure(UUID character, String passive, Throwable cause) { }
|
||||
public record Mutation(CharacterStorage.PassiveMutationStatus status, CharacterRecord record) { }
|
||||
private final PassiveRegistry registry;
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private boolean closed;
|
||||
|
||||
public PassiveEngine(PassiveRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
|
||||
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> purchase(ControlInput input, String passive, BigInteger expectedRevision) {
|
||||
var definition = registry.find(passive).orElse(null);
|
||||
if (closed || definition == null || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.PassiveMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutate(input, passive, expectedRevision, () -> characters.unlockPassive(input.session().character(),
|
||||
expectedRevision, passive, definition.requirements(), definition.prerequisites(), definition.pointCost()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> unlearn(ControlInput input, String passive, BigInteger expectedRevision) {
|
||||
if (closed || registry.find(passive).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.PassiveMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutate(input, passive, expectedRevision, () -> characters.unlearnPassive(input.session().character(),
|
||||
expectedRevision, passive, registry.dependents(passive)));
|
||||
}
|
||||
|
||||
private CompletableFuture<Mutation> mutate(ControlInput input, String passive, BigInteger expectedRevision,
|
||||
java.util.function.Supplier<CompletableFuture<CharacterStorage.PassiveMutation>> operation) {
|
||||
var result = new CompletableFuture<Mutation>();
|
||||
CompletableFuture<CharacterStorage.PassiveMutation> work;
|
||||
try { work = operation.get(); }
|
||||
catch (RuntimeException failure) { report(input.session().character(), passive, failure); result.completeExceptionally(failure); return result; }
|
||||
work.whenComplete((mutation, failure) -> {
|
||||
try { nextTick.accept(() -> {
|
||||
if (failure != null) { report(input.session().character(), passive, failure); result.completeExceptionally(failure); return; }
|
||||
var record = mutation.record().orElse(null);
|
||||
if (mutation.status() == CharacterStorage.PassiveMutationStatus.APPLIED
|
||||
&& (record == null || !input.stillCurrent()
|
||||
|| !sessions.acknowledgeProgress(input.session(), expectedRevision, record))) {
|
||||
result.complete(new Mutation(CharacterStorage.PassiveMutationStatus.STALE, record)); return;
|
||||
}
|
||||
result.complete(new Mutation(mutation.status(), record));
|
||||
}); } catch (RuntimeException rejected) {
|
||||
report(input.session().character(), passive, rejected); result.completeExceptionally(rejected);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private void report(UUID character, String passive, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, passive, failure)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Internal stable-ID passive catalogue consumed by persistence, books and focused runtime services. */
|
||||
public final class PassiveRegistry {
|
||||
private final Map<String, PassiveDefinition> definitions = new LinkedHashMap<>();
|
||||
|
||||
public synchronized void register(PassiveDefinition definition) {
|
||||
Objects.requireNonNull(definition);
|
||||
if (definitions.containsKey(definition.id())) {
|
||||
throw new IllegalArgumentException("Duplicate passive ID: " + definition.id());
|
||||
}
|
||||
validatePrerequisites(definition);
|
||||
definitions.put(definition.id(), definition);
|
||||
}
|
||||
|
||||
public synchronized void replace(PassiveDefinition definition) {
|
||||
Objects.requireNonNull(definition);
|
||||
if (!definitions.containsKey(definition.id())) throw new IllegalArgumentException("Passive is not registered");
|
||||
validatePrerequisites(definition);
|
||||
definitions.put(definition.id(), definition);
|
||||
}
|
||||
|
||||
private void validatePrerequisites(PassiveDefinition definition) {
|
||||
if (!definitions.keySet().containsAll(definition.prerequisites())) {
|
||||
throw new IllegalArgumentException("Passive prerequisite is not registered");
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized Optional<PassiveDefinition> find(String id) {
|
||||
return Optional.ofNullable(definitions.get(id));
|
||||
}
|
||||
public synchronized Collection<PassiveDefinition> definitions() { return java.util.List.copyOf(definitions.values()); }
|
||||
public synchronized java.util.Set<String> dependents(String id) {
|
||||
return definitions.values().stream().filter(definition -> definition.prerequisites().contains(id))
|
||||
.map(PassiveDefinition::id).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Quicker Regeneration I metadata and live configured pulse bonus. */
|
||||
public final class QuickerRegenerationI {
|
||||
public static final String ID = "quicker_regeneration_i";
|
||||
public record Settings(BigInteger bonus) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(bonus);
|
||||
if (bonus.signum() < 0 || bonus.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Quicker Regeneration I bonus");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.ONE); }
|
||||
}
|
||||
private volatile Settings settings;
|
||||
public QuickerRegenerationI(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger bonus() { return settings.bonus(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Quicker Regeneration I",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Add " + bonus() + " health point" + (bonus().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " to each eligible out-of-combat recovery pulse.",
|
||||
"Uses the same capped persisted custom-healing transaction as base recovery."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Quicker Regeneration II metadata and live configured pulse bonus. */
|
||||
public final class QuickerRegenerationII {
|
||||
public static final String ID = "quicker_regeneration_ii";
|
||||
public record Settings(BigInteger bonus) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(bonus);
|
||||
if (bonus.signum() < 0 || bonus.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Quicker Regeneration II bonus");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.TWO); }
|
||||
}
|
||||
private volatile Settings settings;
|
||||
public QuickerRegenerationII(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger bonus() { return settings.bonus(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Quicker Regeneration II",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.valueOf(14)),
|
||||
Set.of(QuickerRegenerationI.ID), BigInteger.ONE,
|
||||
List.of("Add " + bonus() + " health point" + (bonus().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " beyond Quicker Regeneration I to each eligible out-of-combat recovery pulse.",
|
||||
"Both bonuses use the same capped persisted custom-healing transaction as base recovery."));
|
||||
}
|
||||
}
|
||||
@@ -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 Speed I layer for the learned character passive. */
|
||||
public final class SpeedI implements AutoCloseable {
|
||||
public static final String ID = "speed_i";
|
||||
private final PersistentPotionEffect effect;
|
||||
|
||||
public SpeedI(EquipmentSessions sessions) {
|
||||
effect = new PersistentPotionEffect(ID, PotionEffectType.SPEED, 0, true, 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) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
}
|
||||
@@ -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);};}
|
||||
}
|
||||
@@ -16,15 +16,43 @@ import java.util.UUID;
|
||||
public record 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,
|
||||
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,
|
||||
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,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, 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,
|
||||
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 {
|
||||
@@ -34,20 +62,31 @@ 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)
|
||||
|| health.signum() < 0
|
||||
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|
||||
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|
||||
|| loadout.size() != 7 || passives.size() != 3
|
||||
|| passives.stream().filter(passiveId -> !passiveId.isEmpty()).anyMatch(String::isBlank)
|
||||
|| passives.stream().filter(passiveId -> !passiveId.isEmpty()).distinct().count()
|
||||
!= passives.stream().filter(passiveId -> !passiveId.isEmpty()).count()
|
||||
|| learned.stream().anyMatch(String::isBlank)
|
||||
|| 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) {
|
||||
@@ -53,7 +64,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
|| record.dead() || record.deathId() != null || record.level() != 1
|
||||
|| 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.cooldowns().isEmpty()
|
||||
|| !record.learned().isEmpty() || record.passives().stream().anyMatch(id -> !id.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");
|
||||
}
|
||||
@@ -192,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. */
|
||||
@@ -205,8 +230,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var old = found.orElseThrow();
|
||||
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.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
});
|
||||
@@ -246,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.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);
|
||||
}
|
||||
@@ -278,22 +364,92 @@ 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.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);
|
||||
});
|
||||
}
|
||||
|
||||
public enum PassiveMutationStatus {
|
||||
APPLIED, STALE, REQUIREMENTS, NO_POINTS, PASSIVE_SLOTS_FULL, ALREADY_LEARNED,
|
||||
NOT_LEARNED, REQUIRED_BY_LEARNED, UNAVAILABLE
|
||||
}
|
||||
public record PassiveMutation(PassiveMutationStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
/** Learn one registered passive in the lowest empty independent slot as one character revision. */
|
||||
public CompletableFuture<PassiveMutation> unlockPassive(UUID characterId, BigInteger expectedRevision, String passive,
|
||||
java.util.Map<Stat, BigInteger> requirements, Set<String> prerequisites, BigInteger cost) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(passive);
|
||||
Objects.requireNonNull(requirements); Objects.requireNonNull(prerequisites); Objects.requireNonNull(cost);
|
||||
if (!passive.matches("[a-z][a-z0-9_]{0,63}") || !cost.equals(BigInteger.ONE)
|
||||
|| requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)
|
||||
|| prerequisites.stream().anyMatch(String::isBlank)) {
|
||||
throw new IllegalArgumentException("Invalid passive unlock");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new PassiveMutation(PassiveMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new PassiveMutation(PassiveMutationStatus.STALE, Optional.of(old));
|
||||
if (old.passives().contains(passive)) return new PassiveMutation(PassiveMutationStatus.ALREADY_LEARNED, Optional.of(old));
|
||||
if (!old.passives().containsAll(prerequisites) || requirements.entrySet().stream()
|
||||
.anyMatch(entry -> old.stats().get(entry.getKey()).compareTo(entry.getValue()) < 0)) {
|
||||
return new PassiveMutation(PassiveMutationStatus.REQUIREMENTS, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().compareTo(cost) < 0) return new PassiveMutation(PassiveMutationStatus.NO_POINTS, Optional.of(old));
|
||||
int slot = old.passives().indexOf("");
|
||||
if (slot < 0) return new PassiveMutation(PassiveMutationStatus.PASSIVE_SLOTS_FULL, Optional.of(old));
|
||||
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Refund one passive once while refusing removal beneath a registered learned dependent. */
|
||||
public CompletableFuture<PassiveMutation> unlearnPassive(UUID characterId, BigInteger expectedRevision,
|
||||
String passive, Set<String> learnedDependents) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(passive);
|
||||
Objects.requireNonNull(learnedDependents);
|
||||
if (!passive.matches("[a-z][a-z0-9_]{0,63}") || learnedDependents.stream().anyMatch(String::isBlank)) {
|
||||
throw new IllegalArgumentException("Invalid passive refund");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new PassiveMutation(PassiveMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new PassiveMutation(PassiveMutationStatus.STALE, Optional.of(old));
|
||||
if (!old.passives().contains(passive)) return new PassiveMutation(PassiveMutationStatus.NOT_LEARNED, Optional.of(old));
|
||||
if (old.passives().stream().anyMatch(learnedDependents::contains)) {
|
||||
return new PassiveMutation(PassiveMutationStatus.REQUIRED_BY_LEARNED, Optional.of(old));
|
||||
}
|
||||
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).toList();
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
public enum AbilityMutationStatus {
|
||||
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, LOADOUT_FULL, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
|
||||
}
|
||||
@@ -322,8 +478,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var learned = new java.util.HashSet<>(old.learned()); learned.add(ability);
|
||||
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.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -342,8 +498,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var learned = new java.util.HashSet<>(old.learned()); learned.remove(ability);
|
||||
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.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -365,8 +521,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
loadout.set(slot, ability);
|
||||
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.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -389,8 +545,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (readyAt.equals(previous)) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
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(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -434,8 +590,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
|
||||
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.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt());
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -460,8 +616,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
|
||||
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.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
@@ -474,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();
|
||||
@@ -492,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.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);
|
||||
});
|
||||
@@ -528,8 +690,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
|| ageLimited && age.compareTo(Duration.ofHours(24)) >= 0) return Optional.empty();
|
||||
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.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ final class CharacterYaml {
|
||||
root.put("health", record.health().toString());
|
||||
root.put("learned-abilities", record.learned().stream().sorted().toList());
|
||||
root.put("loadout", record.loadout());
|
||||
root.put("learned-passives", record.passives());
|
||||
List<Map<String, String>> cooldowns = new ArrayList<>();
|
||||
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
|
||||
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
|
||||
@@ -53,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());
|
||||
@@ -93,11 +99,20 @@ final class CharacterYaml {
|
||||
return new CharacterRecord(UUID.fromString(text(root.get("character-id"))), text(root.get("name")),
|
||||
number(root.get("revision")), stats, number(root.get("level")).intValueExact(),
|
||||
number(root.get("experience")), number(root.get("unspent-points")),
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), 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);
|
||||
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("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");
|
||||
@@ -112,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,113 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.passive.HealthSteal;
|
||||
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.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 save-before-publication Health Steal balance. */
|
||||
public final class HealthStealSettings implements AutoCloseable {
|
||||
public static final String KEY = "health-steal-healing-per-kill";
|
||||
public static final String DEFAULT = "1";
|
||||
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, HealthSteal.Settings settings, long sequence) { }
|
||||
private final Path directory; private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-health-steal-configuration"));
|
||||
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
|
||||
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public HealthStealSettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
HealthStealSettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
public static HealthSteal.Settings parse(String raw) {
|
||||
try { return new HealthSteal.Settings(new java.math.BigInteger(raw)); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
|
||||
}
|
||||
private static String canonical(String raw) { var parsed = parse(raw); return parsed.healing().toString(); }
|
||||
public String value() { return effective; }
|
||||
public HealthSteal.Settings settings() { 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 java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Health Steal configuration 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.passive.QuickerRegenerationII;
|
||||
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.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 save-before-publication Quicker Regeneration II balance. */
|
||||
public final class QuickerRegenerationIISettings implements AutoCloseable {
|
||||
public static final String KEY = "quicker-regeneration-ii-bonus-healing";
|
||||
public static final String DEFAULT = "2";
|
||||
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, QuickerRegenerationII.Settings settings, long sequence) { }
|
||||
private final Path directory; private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-quicker-regeneration-ii-configuration"));
|
||||
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
|
||||
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public QuickerRegenerationIISettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
QuickerRegenerationIISettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
public static QuickerRegenerationII.Settings parse(String raw) {
|
||||
try { return new QuickerRegenerationII.Settings(new java.math.BigInteger(raw)); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
|
||||
}
|
||||
private static String canonical(String raw) { var parsed = parse(raw); return parsed.bonus().toString(); }
|
||||
public String value() { return effective; }
|
||||
public QuickerRegenerationII.Settings settings() { 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 java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Quicker Regeneration II configuration 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) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
private void save(String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe lock");
|
||||
}
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(20);
|
||||
options.setCodePointLimit(MAX_BYTES); var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes; try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
|
||||
document.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy", busy); }
|
||||
}
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.passive.QuickerRegenerationI;
|
||||
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.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 save-before-publication Quicker Regeneration I balance. */
|
||||
public final class QuickerRegenerationSettings implements AutoCloseable {
|
||||
public static final String KEY = "quicker-regeneration-i-bonus-healing";
|
||||
public static final String DEFAULT = "1";
|
||||
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, QuickerRegenerationI.Settings settings, long sequence) { }
|
||||
private final Path directory; private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-quicker-regeneration-i-configuration"));
|
||||
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
|
||||
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public QuickerRegenerationSettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
QuickerRegenerationSettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
public static QuickerRegenerationI.Settings parse(String raw) {
|
||||
try { return new QuickerRegenerationI.Settings(new java.math.BigInteger(raw)); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
|
||||
}
|
||||
private static String canonical(String raw) { var parsed = parse(raw); return parsed.bonus().toString(); }
|
||||
public String value() { return effective; }
|
||||
public QuickerRegenerationI.Settings settings() { 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 java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Quicker Regeneration I configuration 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) {
|
||||
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); }
|
||||
}
|
||||
@@ -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,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,84 @@
|
||||
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 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(message); } 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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
|
||||
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
|
||||
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
|
||||
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
|
||||
@@ -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,10 +66,27 @@ 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.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
|
||||
private OutOfCombatRecovery outOfCombatRecovery;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
|
||||
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) {
|
||||
@@ -294,6 +313,9 @@ 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 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) {
|
||||
@@ -302,6 +324,30 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
outOfCombatRecovery = Objects.requireNonNull(recovery);
|
||||
}
|
||||
|
||||
public void attachQuickerRegeneration(games.dmg.fantasystick.persistence.QuickerRegenerationSettings settings,
|
||||
games.dmg.fantasystick.passive.QuickerRegenerationI passive,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry registry) {
|
||||
if (closed || quickerRegenerationSettings != null) throw new IllegalStateException("Quicker Regeneration I already attached or command closed");
|
||||
quickerRegenerationSettings = Objects.requireNonNull(settings); quickerRegeneration = Objects.requireNonNull(passive);
|
||||
passiveRegistry = Objects.requireNonNull(registry);
|
||||
}
|
||||
|
||||
public void attachQuickerRegenerationII(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings settings,
|
||||
games.dmg.fantasystick.passive.QuickerRegenerationII passive,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry registry) {
|
||||
if (closed || quickerRegenerationIISettings != null) throw new IllegalStateException("Quicker Regeneration II already attached or command closed");
|
||||
quickerRegenerationIISettings = Objects.requireNonNull(settings); quickerRegenerationII = Objects.requireNonNull(passive);
|
||||
if (passiveRegistry != Objects.requireNonNull(registry)) throw new IllegalArgumentException("Passive registry mismatch");
|
||||
}
|
||||
|
||||
public void attachHealthSteal(games.dmg.fantasystick.persistence.HealthStealSettings settings,
|
||||
games.dmg.fantasystick.passive.HealthSteal passive,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry registry) {
|
||||
if (closed || healthStealSettings != null) throw new IllegalStateException("Health Steal already attached or command closed");
|
||||
healthStealSettings = Objects.requireNonNull(settings); healthSteal = Objects.requireNonNull(passive);
|
||||
if (passiveRegistry != Objects.requireNonNull(registry)) throw new IllegalArgumentException("Passive registry mismatch");
|
||||
}
|
||||
|
||||
public void attachExperienceGrant(AdminExperienceGrant grant) {
|
||||
if (closed || experienceGrant != null) throw new IllegalStateException("Experience grant already attached or command closed");
|
||||
experienceGrant = Objects.requireNonNull(grant);
|
||||
@@ -317,12 +363,40 @@ 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 (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("You need fantasystick.admin to administer Fantasy Stick.");
|
||||
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;
|
||||
@@ -447,6 +521,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;
|
||||
@@ -477,12 +688,26 @@ 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 (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
|
||||
&& games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys().contains(args[1])) {
|
||||
outOfCombatRecoveryConfiguration(sender, args); return;
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY)
|
||||
&& quickerRegenerationSettings != null) {
|
||||
quickerRegenerationConfiguration(sender, args); return;
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY)
|
||||
&& quickerRegenerationIISettings != null) {
|
||||
quickerRegenerationIIConfiguration(sender, args); return;
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.HealthStealSettings.KEY) && healthStealSettings != null) {
|
||||
healthStealConfiguration(sender, args); return;
|
||||
}
|
||||
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
longbowConfiguration(sender, args); return;
|
||||
}
|
||||
@@ -531,6 +756,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;
|
||||
}
|
||||
@@ -565,6 +793,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) {
|
||||
@@ -598,6 +853,93 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private void quickerRegenerationConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY + "="
|
||||
+ quickerRegenerationSettings.value() + " (applies to future recovery pulses only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationSettings.Update> operation;
|
||||
try { operation = quickerRegenerationSettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Quicker Regeneration I setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-quicker-regeneration-i", failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = quickerRegenerationSettings.publish(saved);
|
||||
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition()); }
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Quicker Regeneration I configuration save failed; effective value is unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
|
||||
+ games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY + "=" + quickerRegenerationSettings.value() + ".");
|
||||
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY
|
||||
+ "=" + saved.value() + " for future recovery pulses.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void quickerRegenerationIIConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "="
|
||||
+ quickerRegenerationIISettings.value() + " (applies to future recovery pulses only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.Update> operation;
|
||||
try { operation = quickerRegenerationIISettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Quicker Regeneration II setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-quicker-regeneration-ii", failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = quickerRegenerationIISettings.publish(saved);
|
||||
if (applied) { quickerRegenerationII.configure(saved.settings()); passiveRegistry.replace(quickerRegenerationII.definition()); }
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Quicker Regeneration II configuration save failed; effective value is unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
|
||||
+ games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "=" + quickerRegenerationIISettings.value() + ".");
|
||||
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY
|
||||
+ "=" + saved.value() + " for future recovery pulses.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void healthStealConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "="
|
||||
+ healthStealSettings.value() + " (applies to later qualifying final blows only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HealthStealSettings.Update> operation;
|
||||
try { operation = healthStealSettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Health Steal setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-health-steal", failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = healthStealSettings.publish(saved);
|
||||
if (applied) { healthSteal.configure(saved.settings()); passiveRegistry.replace(healthSteal.definition()); }
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Health Steal configuration save failed; effective value is unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
|
||||
+ games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "=" + healthStealSettings.value() + ".");
|
||||
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.HealthStealSettings.KEY
|
||||
+ "=" + saved.value() + " for later qualifying final blows.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void longbowConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
@@ -971,6 +1313,12 @@ 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 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);}});}
|
||||
@@ -1015,7 +1363,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (closed || !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("config", "experience", "give", "inspect", "party-registrar", "recover", "revive", "shop", "training-dummy")
|
||||
: List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"), args[0]);
|
||||
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(
|
||||
@@ -1025,7 +1384,14 @@ 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.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());
|
||||
@@ -1051,6 +1417,9 @@ 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(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])) {
|
||||
@@ -1058,6 +1427,28 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
String defaults = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY)
|
||||
&& quickerRegenerationSettings != null) {
|
||||
String current = quickerRegenerationSettings.value();
|
||||
return matching(current.equals(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT)
|
||||
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT), args[2]);
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY)
|
||||
&& quickerRegenerationIISettings != null) {
|
||||
String current = quickerRegenerationIISettings.value();
|
||||
return matching(current.equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT)
|
||||
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT), args[2]);
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.HealthStealSettings.KEY) && healthStealSettings != null) {
|
||||
String current = healthStealSettings.value();
|
||||
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,6 +286,7 @@ 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(gear!=null&&!previous.gear().equals(saved.gear()))gear.install(attempt.player.getInventory(),saved,attempt.owner,attempt.session);
|
||||
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
|
||||
&& !refreshControls(attempt, session)) return false;
|
||||
return true;
|
||||
@@ -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,7 +63,9 @@ 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<>();
|
||||
private final ArrayDeque<UUID> order = new ArrayDeque<>();
|
||||
private final Set<UUID> queued = new HashSet<>();
|
||||
@@ -72,15 +76,27 @@ 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); }
|
||||
|
||||
/** Install the learned-passive bonus before recovery evaluation begins. */
|
||||
public synchronized void attachBonusHealing(java.util.function.Function<CharacterRecord, BigInteger> bonus) {
|
||||
if (closed || task != null || !states.isEmpty()) throw new IllegalStateException("Recovery evaluation already started");
|
||||
bonusHealing = Objects.requireNonNull(bonus);
|
||||
}
|
||||
|
||||
public synchronized void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || task != null) throw new IllegalStateException("Out-of-combat recovery is already installed or closed");
|
||||
@@ -161,7 +177,11 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
|
||||
state.lastAttempt = now; // Full-health checks are not banked for later replay.
|
||||
return;
|
||||
}
|
||||
double amount = configured.amount().doubleValue();
|
||||
BigInteger exactAmount;
|
||||
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record))); }
|
||||
catch (RuntimeException failure) { state.lastAttempt = now; report(record.id(), failure); return; }
|
||||
if (exactAmount.signum() < 0) { state.lastAttempt = now; report(record.id(), new IllegalStateException("Negative recovery bonus")); return; }
|
||||
double amount = exactAmount.bitLength() > 1023 ? maximum - current : exactAmount.doubleValue();
|
||||
double proposed = Math.min(maximum - current, amount);
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) return;
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
@@ -214,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&¤t.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");}}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import java.util.*;import java.util.function.BiConsumer;import org.bukkit.*;import org.bukkit.entity.*;import org.bukkit.event.*;import org.bukkit.persistence.PersistentDataType;import org.bukkit.plugin.Plugin;
|
||||
/** Loaded-chunk Armorer reconciliation and native protection, distinct from Party Registrars. */
|
||||
public final class FantasyArmorerController implements Listener,AutoCloseable{
|
||||
private final Plugin plugin;private final FantasyArmorerDirectory directory;private final BiConsumer<Player,UUID> interactions;private final NamespacedKey idKey,typeKey;private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private boolean closed;public FantasyArmorerController(Plugin plugin,FantasyArmorerDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"fantasy_armorer_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 "fantasy_armorer".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,FantasyArmorer>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&§ion(value.x())==chunk.getX()&§ion(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 target:desired.values())if(!retained.contains(target.id()))spawn(target);}
|
||||
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(FantasyArmorer value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=section(value.x()),z=section(value.z());if(world.isChunkLoaded(x,z))world.spawn(location(value),Villager.class,villager->configure(villager,value));}private void configure(Villager villager,FantasyArmorer value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,"fantasy_armorer");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.name().isBlank()?"Fantasy Armorer":value.name()));villager.setCustomNameVisible(true);}private Location location(FantasyArmorer value){return new Location(plugin.getServer().getWorld(value.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 section(double value){return((int)Math.floor(value))>>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,9 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository;import java.io.*;import java.util.*;import java.util.concurrent.*;
|
||||
/** Serialized durable Fantasy Armorer desired state. */
|
||||
public final class FantasyArmorerDirectory implements AutoCloseable{
|
||||
private final YamlFantasyArmorerRepository repository;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armorer-persistence"));private volatile List<FantasyArmorer> snapshot;private final Map<UUID,Integer> leases=new HashMap<>();private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();public FantasyArmorerDirectory(YamlFantasyArmorerRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}public List<FantasyArmorer> all(){return snapshot;}public Optional<FantasyArmorer> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<FantasyArmorer> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new FantasyArmorer(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<FantasyArmorer>> remove(UUID id){return submit(()->{synchronized(this){var found=find(id);if(found.isEmpty())return Optional.empty();if(leases.getOrDefault(id,0)>0)throw new IOException("Fantasy Armorer has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<FantasyArmorer> restore(FantasyArmorer value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Armorer identity collision");}var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}
|
||||
public synchronized Optional<Lease> acquire(UUID id){if(closed||find(id).isEmpty())return Optional.empty();leases.merge(id,1,Integer::sum);return Optional.of(new Lease(id));}public final class Lease implements AutoCloseable{private final UUID id;private boolean released;private Lease(UUID id){this.id=id;}public FantasyArmorer shop(){return find(id).orElseThrow();}@Override public void close(){synchronized(FantasyArmorerDirectory.this){if(released)return;released=true;int remaining=leases.getOrDefault(id,0)-1;if(remaining<=0)leases.remove(id);else leases.put(id,remaining);}}}
|
||||
private <T>CompletableFuture<T> submit(Callable<T> operation){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Armorer directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(operation.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}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);}
|
||||
}
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user