Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16087bdfec |
No files matched your search
@@ -103,6 +103,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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.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() {
|
||||
@@ -133,6 +137,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.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 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),
|
||||
@@ -336,6 +343,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()));
|
||||
quickerRegenerationSettings = new games.dmg.fantasystick.persistence.QuickerRegenerationSettings(
|
||||
getDataFolder().toPath(), quickerRegenerationInitial.bonus().toString());
|
||||
quickerRegeneration = new games.dmg.fantasystick.passive.QuickerRegenerationI(quickerRegenerationInitial);
|
||||
passiveRegistry = new games.dmg.fantasystick.passive.PassiveRegistry();
|
||||
passiveRegistry.register(quickerRegeneration.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 -> record.passives().contains(
|
||||
games.dmg.fantasystick.passive.QuickerRegenerationI.ID) ? quickerRegeneration.bonus() : java.math.BigInteger.ZERO);
|
||||
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);
|
||||
@@ -356,7 +373,7 @@ 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);
|
||||
}, abilityRegistry, abilityEngine, passiveRegistry, passiveEngine);
|
||||
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
|
||||
basicAttack = localBasicAttack;
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
|
||||
@@ -391,6 +408,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachPartySettings(partySettings);
|
||||
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
|
||||
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, 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()
|
||||
@@ -531,6 +549,9 @@ 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 closingPassiveEngine = passiveEngine; passiveEngine = null;
|
||||
quickerRegeneration = 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);
|
||||
@@ -568,6 +589,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
@@ -616,7 +638,9 @@ 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());
|
||||
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,6 +661,7 @@ 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 (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) -> {
|
||||
@@ -122,25 +144,47 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
.sorted(java.util.Comparator.comparing(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);
|
||||
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 details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
|
||||
var passiveDetails = passiveDefinitions.stream()
|
||||
.map(definition -> passiveDetailPages(record, definition, passiveView)).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));
|
||||
int activeCount = activePages(record, definitions, placeholderTargets).size();
|
||||
int passiveCount = passiveSlotPages(record, passiveDefinitions, passivePlaceholderTargets).size();
|
||||
int indexCount = indexPages(definitions, placeholderTargets).size();
|
||||
int passiveIndexCount = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets).size();
|
||||
var 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();
|
||||
}
|
||||
var passiveTargets = new java.util.HashMap<String, Integer>();
|
||||
for (int index = 0; index < passiveDefinitions.size(); index++) {
|
||||
passiveTargets.put(passiveDefinitions.get(index).id(), target);
|
||||
target += passiveDetails.get(index).size();
|
||||
}
|
||||
pages.addAll(activePages(record, definitions, targets));
|
||||
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
|
||||
pages.addAll(indexPages(definitions, targets));
|
||||
pages.addAll(passiveIndexPages(passiveDefinitions, passiveTargets));
|
||||
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,
|
||||
@@ -208,6 +252,20 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
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 static java.util.List<Component> indexPages(
|
||||
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
|
||||
var pages = new java.util.ArrayList<Component>();
|
||||
@@ -221,6 +279,43 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
|
||||
private static java.util.List<Component> passiveIndexPages(
|
||||
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> 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("Passive index"); builder.blank();
|
||||
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
|
||||
builder.link("", definition.name(), targets.get(definition.id()));
|
||||
}
|
||||
pages.addAll(builder.pages());
|
||||
}
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
|
||||
private static java.util.List<Component> passiveDetailPages(CharacterRecord record,
|
||||
games.dmg.fantasystick.passive.PassiveDefinition definition, PassiveView view) {
|
||||
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().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);
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static java.util.List<Component> abilityPages(CharacterRecord record,
|
||||
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
|
||||
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
|
||||
@@ -308,6 +403,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 +526,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 +597,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();
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,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))));
|
||||
|
||||
@@ -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,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."));
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ 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) {
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
@@ -24,7 +24,16 @@ public record CharacterRecord(
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public CharacterRecord {
|
||||
@@ -37,6 +46,7 @@ public record CharacterRecord(
|
||||
stats = Map.copyOf(stats);
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
passives = List.copyOf(passives);
|
||||
cooldowns = Map.copyOf(cooldowns);
|
||||
restrictedKills = Map.copyOf(restrictedKills);
|
||||
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|
||||
@@ -44,7 +54,11 @@ public record CharacterRecord(
|
||||
|| 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()))
|
||||
|
||||
@@ -53,7 +53,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.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
|
||||
throw new IllegalArgumentException("Craft batch requires distinct new characters");
|
||||
}
|
||||
@@ -205,7 +206,7 @@ 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.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
@@ -246,7 +247,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
@@ -288,12 +289,80 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
|
||||
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
|
||||
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());
|
||||
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());
|
||||
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,7 +391,7 @@ 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.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
@@ -342,7 +411,7 @@ 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.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
@@ -365,7 +434,7 @@ 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.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
@@ -389,7 +458,7 @@ 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.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
@@ -434,7 +503,7 @@ 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,
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
@@ -460,7 +529,7 @@ 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.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
@@ -492,7 +561,7 @@ 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);
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
@@ -528,7 +597,7 @@ 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.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
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())));
|
||||
@@ -93,8 +94,9 @@ 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,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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){}}
|
||||
|
||||
@@ -68,6 +68,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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.passive.PassiveRegistry passiveRegistry;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -302,6 +305,14 @@ 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 attachExperienceGrant(AdminExperienceGrant grant) {
|
||||
if (closed || experienceGrant != null) throw new IllegalStateException("Experience grant already attached or command closed");
|
||||
experienceGrant = Objects.requireNonNull(grant);
|
||||
@@ -483,6 +494,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
&& 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 (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
longbowConfiguration(sender, args); return;
|
||||
}
|
||||
@@ -598,6 +613,35 @@ 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 longbowConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
@@ -1026,6 +1070,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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.OutOfCombatRecoverySettings.keys());
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
|
||||
@@ -1058,6 +1103,12 @@ 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 (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]);
|
||||
|
||||
@@ -62,6 +62,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<Failure> failures;
|
||||
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<>();
|
||||
@@ -81,6 +82,12 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
|
||||
|
||||
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 +168,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);
|
||||
|
||||
@@ -15,6 +15,7 @@ sheet-resource-pack-sha1: ''
|
||||
out-of-combat-healing-delay-seconds: 30
|
||||
out-of-combat-healing-interval-seconds: 10
|
||||
out-of-combat-healing-amount: 1
|
||||
quicker-regeneration-i-bonus-healing: 1
|
||||
|
||||
# New-character and transferable fantasy progression defaults.
|
||||
starting-points: 40
|
||||
|
||||
@@ -113,6 +113,23 @@ class OutOfCombatRecoveryNativeTest {
|
||||
stale.clock.advance(Duration.ofSeconds(29)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
|
||||
}
|
||||
|
||||
@Test void passiveBonusZeroCancellationAndFullHealthStayInTheBasePulseTransaction() {
|
||||
var zero = new Direct(); zero.recovery.attachBonusHealing(ignored -> BigInteger.ZERO);
|
||||
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(30)); zero.recovery.sweep(); zero.runNext();
|
||||
assertEquals(5, zero.health.get(), "A zero passive bonus leaves the base pulse unchanged");
|
||||
|
||||
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); cancelled.veto = true;
|
||||
var cancelledRevision = cancelled.record.get().revision();
|
||||
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(30)); cancelled.recovery.sweep();
|
||||
assertEquals(4, cancelled.events.getFirst().getAmount());
|
||||
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());
|
||||
|
||||
var full = new Direct(); full.health.set(10d); full.record.set(full.saved(BigDecimal.TEN));
|
||||
full.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); var fullRevision = full.record.get().revision();
|
||||
full.recovery.sweep(); full.clock.advance(Duration.ofSeconds(30)); full.recovery.sweep();
|
||||
assertTrue(full.events.isEmpty()); assertEquals(fullRevision, full.record.get().revision());
|
||||
}
|
||||
|
||||
@Test void zeroAmountDisablesPulsesAndReconfigurationRetainsTheActivityTimestamp() {
|
||||
var f = new Direct(new OutOfCombatRecovery.Settings(Duration.ofSeconds(30), Duration.ofSeconds(10), BigInteger.ZERO));
|
||||
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(40)); f.recovery.sweep();
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.OutOfCombatRecovery;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuickerRegenerationNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void connectedBookLearnRecoveryTransferTailReloadAndUnlearnPathUsesSeparatePassiveSlots() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"character-swap-cooldown-seconds: 0\nout-of-combat-healing-delay-seconds: 0\n"
|
||||
+ "out-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n"
|
||||
+ "quicker-regeneration-i-bonus-healing: 3\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); makeEligible(data, a, BigDecimal.valueOf(4), List.of("", "", ""));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
|
||||
var pages = nativePages(a);
|
||||
int active = first(pages, "Active abilities"), passive = first(pages, "Passive abilities");
|
||||
int activeIndex = first(pages, "Ability index"), passiveIndex = first(pages, "Passive index");
|
||||
int party = first(pages, "Party");
|
||||
assertTrue(active > 0 && active < passive && passive < activeIndex && activeIndex < passiveIndex && passiveIndex < party);
|
||||
assertTrue(pages.get(passive).getString().contains("1: No Passive"));
|
||||
assertTrue(pages.get(passiveIndex).getString().replace("\n", "").contains("Quicker Regeneration I"));
|
||||
var jump = firstChangePage(pages.get(passiveIndex)); assertNotNull(jump);
|
||||
String detail = pages.get(jump.page() - 1).getString();
|
||||
assertTrue(detail.replaceAll("\\s+", "").startsWith("QuickerRegenerationICON12,WIS12[Learn]"), detail);
|
||||
assertTrue(detail.replaceAll("\\s+", "").contains("out-of-combatrecoverypulse"));
|
||||
|
||||
var beforeLoadout = storage(f).find(a.core.record.id()).get().orElseThrow().loadout();
|
||||
PartyBookNativeTest.click(f, a, custom(nativePages(a), "fantasystick:passive_unlock"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var learned = storage(f).find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(List.of("quicker_regeneration_i", "", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
|
||||
assertEquals(beforeLoadout, learned.loadout(), "Passive acquisition never consumes an active slot");
|
||||
|
||||
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
|
||||
recovery.sweep(); PartyBookNativeTest.drain(f);
|
||||
assertEquals(8, a.player.getHealth(), "Base one plus configured passive three is one native persisted pulse");
|
||||
var healed = storage(f).find(a.core.record.id()).get().orElseThrow(); assertEquals(0, new BigDecimal("8").compareTo(healed.health()));
|
||||
|
||||
var dead = storage(f).recordDeath(healed.id(), healed.revision()).get().orElseThrow();
|
||||
var tailText = text(new games.dmg.fantasystick.items.TailBooks().create(dead));
|
||||
assertTrue(tailText.replaceAll("\\s+", "").contains("Passive:quicker_regeneration_i"));
|
||||
var revived = storage(f).restoreDeathRecordAdministratively(dead.id(), dead.deathId(), new BigDecimal("1024")).get().orElseThrow();
|
||||
assertEquals(healed.passives(), revived.passives());
|
||||
|
||||
// Reissue the current same-ID sheet to model the supported transfer after revival.
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(revived));
|
||||
f.players.put(b.core.id, b.player); f.activate(b); PartyBookNativeTest.open(f, b);
|
||||
assertTrue(PartyBookNativeTest.text(b).replace("\n", "").contains("Quicker Regeneration I"));
|
||||
PartyBookNativeTest.click(f, b, custom(nativePages(b), "fantasystick:passive_unlearn"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage(f).find(revived.id()).get().orElseThrow();
|
||||
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertEquals(beforeLoadout, refunded.loadout());
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertEquals(refunded.passives(), storage(f).find(refunded.id()).get().orElseThrow().passives());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unknownFullPassiveSlotsRemainVisibleAndRefuseBeforeSpending() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, a, BigDecimal.valueOf(4), List.of("unknown_a", "unknown_b", "unknown_c"));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
|
||||
String rendered = PartyBookNativeTest.text(a);
|
||||
for (String id : List.of("unknown_a", "unknown_b", "unknown_c")) assertTrue(rendered.contains(id));
|
||||
assertTrue(rendered.replaceAll("\\s+", "").contains("Unavailable:passiveslots"));
|
||||
var before = storage(f).find(a.core.record.id()).get().orElseThrow();
|
||||
assertFalse(hasCustom(nativePages(a), "fantasystick:passive_unlock"));
|
||||
assertEquals(before, storage(f).find(before.id()).get().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void liveBonusConfigurationIsPermissionAwareDurableAndFuturePulseOnly() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("quicker-regeneration-i-bonus-healing"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker"}));
|
||||
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "quicker-regeneration-i-bonus-healing", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing"});
|
||||
assertTrue(sender.messages.getLast().contains("=1"));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing", "4"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing", "4"});
|
||||
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing", "1025"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var settings = (games.dmg.fantasystick.persistence.QuickerRegenerationSettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "quickerRegenerationSettings");
|
||||
assertEquals("4", settings.value());
|
||||
}
|
||||
}
|
||||
|
||||
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
|
||||
List<String> passives) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(40), health, old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static List<net.minecraft.network.chat.Component> nativePages(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT).pages().stream().map(page -> page.raw()).toList();
|
||||
}
|
||||
private static int first(List<net.minecraft.network.chat.Component> pages, String text) {
|
||||
for (int i = 0; i < pages.size(); i++) if (pages.get(i).getString().startsWith(text)) return i;
|
||||
return -1;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage firstChangePage(net.minecraft.network.chat.Component component) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page) return page;
|
||||
for (var child : component.getSiblings()) { var found = firstChangePage(child); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom custom(List<net.minecraft.network.chat.Component> pages, String id) {
|
||||
for (var page : pages) { var found = custom(page, id); if (found != null) return found; }
|
||||
throw new AssertionError("Missing custom action " + id);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom custom(net.minecraft.network.chat.Component component, String id) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom action
|
||||
&& action.id().toString().equals(id)) return action;
|
||||
for (var child : component.getSiblings()) { var found = custom(child, id); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static boolean hasCustom(List<net.minecraft.network.chat.Component> pages, String id) {
|
||||
return pages.stream().anyMatch(page -> hasCustom(page, id));
|
||||
}
|
||||
private static boolean hasCustom(net.minecraft.network.chat.Component component, String id) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
|
||||
&& custom.id().toString().equals(id)) return true;
|
||||
return component.getSiblings().stream().anyMatch(child -> hasCustom(child, id));
|
||||
}
|
||||
private static String text(org.bukkit.inventory.ItemStack book) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
return content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,8 @@ class DistributionTest {
|
||||
assertTrue(defaults.contains("sheet-resource-pack-url: ''"));
|
||||
assertTrue(defaults.contains("sheet-resource-pack-sha1: ''"));
|
||||
for (String setting : java.util.List.of("out-of-combat-healing-delay-seconds: 30",
|
||||
"out-of-combat-healing-interval-seconds: 10", "out-of-combat-healing-amount: 1")) {
|
||||
"out-of-combat-healing-interval-seconds: 10", "out-of-combat-healing-amount: 1",
|
||||
"quicker-regeneration-i-bonus-healing: 1")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged recovery default: " + setting);
|
||||
}
|
||||
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PassiveRegistryTest {
|
||||
@Test void rejectsDuplicateAndMissingDependenciesWithoutLosingRegisteredDefinition() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new PassiveDefinition("costly", "Costly", Map.of(), Set.of(),
|
||||
BigInteger.TWO, List.of("Effect")));
|
||||
var registry = new PassiveRegistry();
|
||||
var original = definition("first", Set.of()); registry.register(original);
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.register(original));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.register(definition("second", Set.of("missing"))));
|
||||
assertTrue(registry.find("second").isEmpty());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.replace(definition("first", Set.of("missing"))));
|
||||
assertEquals(original, registry.find("first").orElseThrow());
|
||||
}
|
||||
|
||||
private static PassiveDefinition definition(String id, Set<String> prerequisites) {
|
||||
return new PassiveDefinition(id, id, Map.of(), prerequisites, BigInteger.ONE, List.of("Effect"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PassiveStorageTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void learnsLowestSlotPersistsUnknownIdsAndRefundsExactlyOnceWithoutChangingActiveLoadout() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var eligible = eligible(List.of("", "future_unknown", "")); repository.create(eligible);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var learned = storage.unlockPassive(eligible.id(), eligible.revision(), "quicker_regeneration_i",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE).get();
|
||||
assertEquals(CharacterStorage.PassiveMutationStatus.APPLIED, learned.status());
|
||||
var saved = learned.record().orElseThrow();
|
||||
assertEquals(List.of("quicker_regeneration_i", "future_unknown", ""), saved.passives());
|
||||
assertEquals(BigInteger.valueOf(39), saved.unspentPoints());
|
||||
assertEquals(eligible.loadout(), saved.loadout());
|
||||
assertEquals(CharacterStorage.PassiveMutationStatus.STALE, storage.unlockPassive(eligible.id(), eligible.revision(),
|
||||
"quicker_regeneration_i", Map.of(Stat.CON, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE).get().status());
|
||||
|
||||
var refunded = storage.unlearnPassive(saved.id(), saved.revision(), "quicker_regeneration_i", Set.of()).get();
|
||||
assertEquals(CharacterStorage.PassiveMutationStatus.APPLIED, refunded.status());
|
||||
assertEquals(List.of("", "future_unknown", ""), refunded.record().orElseThrow().passives());
|
||||
assertEquals(BigInteger.valueOf(40), refunded.record().orElseThrow().unspentPoints());
|
||||
assertEquals(CharacterStorage.PassiveMutationStatus.NOT_LEARNED,
|
||||
storage.unlearnPassive(saved.id(), refunded.record().orElseThrow().revision(),
|
||||
"quicker_regeneration_i", Set.of()).get().status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void requirementsPointsCapacityDependenciesAndDeadStateRefuseWithoutMutation() throws Exception {
|
||||
for (int route = 0; route < 5; route++) {
|
||||
var directory = data.resolve("route-" + route); var repository = new YamlCharacterRepository(directory);
|
||||
var base = eligible(route == 2 ? List.of("unknown_a", "unknown_b", "unknown_c") : List.of("", "", ""));
|
||||
var stats = new EnumMap<>(base.stats());
|
||||
var points = base.unspentPoints(); var dead = false; var death = (java.util.UUID) null; var died = (Instant) null;
|
||||
Set<String> prerequisite = Set.of();
|
||||
if (route == 0) stats.put(Stat.CON, BigInteger.valueOf(11));
|
||||
if (route == 1) points = BigInteger.ZERO;
|
||||
if (route == 3) prerequisite = Set.of("required_passive");
|
||||
if (route == 4) { dead = true; death = java.util.UUID.randomUUID(); died = Instant.parse("2026-09-21T00:00:00Z"); }
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(), points,
|
||||
dead ? BigDecimal.ZERO : base.health(), base.learned(), base.loadout(), base.passives(), base.cooldowns(),
|
||||
base.restrictedKills(), dead, death, died);
|
||||
repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var result = storage.unlockPassive(record.id(), record.revision(), "quicker_regeneration_i",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), prerequisite, BigInteger.ONE).get();
|
||||
var expected = switch (route) {
|
||||
case 0, 3 -> CharacterStorage.PassiveMutationStatus.REQUIREMENTS;
|
||||
case 1 -> CharacterStorage.PassiveMutationStatus.NO_POINTS;
|
||||
case 2 -> CharacterStorage.PassiveMutationStatus.PASSIVE_SLOTS_FULL;
|
||||
default -> CharacterStorage.PassiveMutationStatus.UNAVAILABLE;
|
||||
};
|
||||
assertEquals(expected, result.status());
|
||||
assertEquals(record, repository.load(record.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedSaveLeavesPointsSlotsAndRevisionUnchanged() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var record = eligible(List.of("", "", "")); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC());
|
||||
var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage.unlockPassive(record.id(), record.revision(),
|
||||
"quicker_regeneration_i", Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)),
|
||||
Set.of(), BigInteger.ONE).get());
|
||||
assertEquals(record, repository.load(record.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void yamlLegacyTailDeathAndReviveRetainOrderedPassives() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var record = eligible(List.of("quicker_regeneration_i", "future_unknown", ""));
|
||||
repository.create(record);
|
||||
assertEquals(record, repository.load(record.id()).orElseThrow());
|
||||
String yaml = java.nio.file.Files.readString(data.resolve("characters").resolve(record.id() + ".yml"));
|
||||
assertTrue(yaml.contains("learned-passives:")); assertTrue(yaml.contains("future_unknown"));
|
||||
assertEquals(List.of("", "", ""), CharacterYaml.decode(yaml.replaceFirst("(?m)^learned-passives:.*\\R", "")).passives());
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-21T00:00:00Z"), ZoneOffset.UTC);
|
||||
try (var storage = new CharacterStorage(repository, clock)) {
|
||||
var dead = storage.recordDeath(record.id(), record.revision()).get().orElseThrow();
|
||||
assertEquals(record.passives(), dead.passives());
|
||||
var revived = storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), new BigDecimal("1024")).get().orElseThrow();
|
||||
assertEquals(record.passives(), revived.passives());
|
||||
}
|
||||
}
|
||||
|
||||
private static CharacterRecord eligible(List<String> passives) {
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(base.stats()); stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
|
||||
return new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
BigInteger.valueOf(40), BigDecimal.valueOf(10), base.learned(), base.loadout(), passives, base.cooldowns(),
|
||||
base.restrictedKills(), false, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuickerRegenerationSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void parsesReviewedBoundsAndPersistsBeforePublication() throws Exception {
|
||||
assertEquals(java.math.BigInteger.ONE, QuickerRegenerationSettings.parse("1").bonus());
|
||||
assertEquals(java.math.BigInteger.ZERO, QuickerRegenerationSettings.parse("0").bonus());
|
||||
assertEquals(java.math.BigInteger.valueOf(1024), QuickerRegenerationSettings.parse("1024").bonus());
|
||||
for (String invalid : java.util.List.of("-1", "1025", "1.5", "x")) {
|
||||
assertThrows(IllegalArgumentException.class, () -> QuickerRegenerationSettings.parse(invalid));
|
||||
}
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new QuickerRegenerationSettings(data, "1")) {
|
||||
var update = settings.set("3").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals("1", settings.value());
|
||||
assertTrue(settings.publish(update));
|
||||
assertEquals("3", settings.value());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("3", String.valueOf(saved.get(QuickerRegenerationSettings.KEY)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReplacementAndCloseRetainTheEffectiveValue() throws Exception {
|
||||
try (var settings = new QuickerRegenerationSettings(data, "1", (from, to) -> {
|
||||
throw new java.io.IOException("simulated");
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set("2").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("1", settings.value());
|
||||
}
|
||||
var settings = new QuickerRegenerationSettings(data.resolve("closed"), "1"); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set("2").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user