Compare commits

...
1 Commits
Author SHA1 Message Date
dmg bc8fe44033 feat(recovery): heal outside combat
Release / release (push) Successful in 11m2s
2026-09-20 22:57:23 -04:00
12 changed files with 858 additions and 19 deletions

No files matched your search

@@ -101,6 +101,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
private games.dmg.fantasystick.session.OutOfCombatRecovery outOfCombatRecovery;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -129,6 +131,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
var recoveryConfiguration = outOfCombatRecoveryConfiguration();
var recoveryInitial = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(recoveryConfiguration);
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),
@@ -325,6 +329,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
outOfCombatRecoverySettings = new games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings(
getDataFolder().toPath(), recoveryConfiguration);
outOfCombatRecovery = new games.dmg.fantasystick.session.OutOfCombatRecovery(recoveryInitial, characters, sessions,
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING,
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()));
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);
@@ -379,6 +390,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
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()
@@ -417,6 +429,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(adminRevival, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
getServer().getPluginManager().registerEvents(outOfCombatRecovery, this);
outOfCombatRecovery.install(this);
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
@@ -515,6 +529,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingRevivals = revivals; revivals = null;
abilityRegistry = null;
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
var closingOutOfCombatRecovery = outOfCombatRecovery; outOfCombatRecovery = null;
var closingOutOfCombatRecoverySettings = outOfCombatRecoverySettings; outOfCombatRecoverySettings = 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);
@@ -551,6 +567,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
if (closingOutOfCombatRecoverySettings != null) cleanup("out-of-combat recovery configuration", closingOutOfCombatRecoverySettings::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
@@ -597,7 +614,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartySettings.drained().toCompletableFuture(),
closingSheetResourcePackSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSheetResourcePackSettings.drained().toCompletableFuture());
: closingSheetResourcePackSettings.drained().toCompletableFuture(),
closingOutOfCombatRecoverySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingOutOfCombatRecoverySettings.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);
@@ -617,6 +636,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingRevive != null) cleanup("Revive", closingRevive::close);
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 (closingVisuals != null) cleanup("session particles", closingVisuals::close);
if (closingParties != null) cleanup("parties", closingParties::close);
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
@@ -642,6 +662,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingAdminRevival);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingOutOfCombatRecovery);
unregister(closingBooks);
unregister(closingEvents);
unregister(closingCrafting);
@@ -711,6 +732,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private java.util.Map<String, String> outOfCombatRecoveryConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> frostShardConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.FrostShardSettings.keys()) {
@@ -111,23 +111,12 @@ public final class CombatExperience implements Listener, AutoCloseable {
public void damage(EntityDamageEvent event) {
if (closed || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
|| event.getFinalDamage() <= 0) return;
var origin = source(event);
var origin = sources.source(event);
if (origin == null || origin.session() == null || !sources.current(origin)
|| event.getEntity().getUniqueId().equals(origin.player())) return;
contributions.accept(event.getEntity().getUniqueId(), origin.character(), clock.instant());
}
private CombatSources.Origin source(EntityDamageEvent event) {
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null) return ongoing.source();
}
var origin = sources.read(event.getDamageSource().getDirectEntity());
if (origin == null && event instanceof EntityDamageByEntityEvent byEntity) origin = sources.read(byEntity.getDamager());
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
return origin;
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
public void beforePlayerDeath(PlayerDeathEvent event) {
if (closed) return;
@@ -10,9 +10,9 @@ import org.bukkit.entity.Projectile;
import org.bukkit.persistence.PersistentDataType;
/** Source identity travels with delayed native entities, never with a replacement sheet or shooter. */
final class CombatSources {
public final class CombatSources {
static final NamespacedKey ORIGIN = new NamespacedKey("fantasystick", "combat_origin_v1");
record Origin(UUID player, UUID session, UUID character) {
public record Origin(UUID player, UUID session, UUID character) {
String encode() { return player + "/" + (session == null ? "-/-" : session + "/" + character); }
static Origin decode(String value) {
if (value == null || value.length() > 110) return null;
@@ -26,15 +26,27 @@ final class CombatSources {
}
}
private final EquipmentSessions sessions;
CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
public CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
Origin player(UUID player) {
return sessions.active(player).map(active -> new Origin(player, active.session(), active.character()))
.orElseGet(() -> new Origin(player, null, null));
}
boolean current(Origin origin) {
public boolean current(Origin origin) {
return origin.equals(player(origin.player())) && (origin.session() == null
|| sessions.current(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character())));
}
public Origin source(org.bukkit.event.entity.EntityDamageEvent event) {
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null) return ongoing.source();
}
var origin = read(event.getDamageSource().getDirectEntity());
if (origin == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity) {
origin = read(byEntity.getDamager());
}
if (origin == null) origin = read(event.getDamageSource().getCausingEntity());
return origin;
}
Origin read(Entity entity) { return read(entity, 0); }
private Origin read(Entity entity, int depth) {
if (entity == null || depth >= 8) return null;
@@ -0,0 +1,184 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.session.OutOfCombatRecovery;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publication settings for active-session out-of-combat recovery. */
public final class OutOfCombatRecoverySettings implements AutoCloseable {
public static final String DELAY = "out-of-combat-healing-delay-seconds";
public static final String INTERVAL = "out-of-combat-healing-interval-seconds";
public static final String AMOUNT = "out-of-combat-healing-amount";
private static final List<String> KEYS = List.of(DELAY, INTERVAL, AMOUNT);
private static final Map<String, String> DEFAULTS = Map.of(DELAY, "30", INTERVAL, "10", AMOUNT, "1");
private static final int MAX_BYTES = 1_048_576;
private static final long MAX_SECONDS = 31_536_000L;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, OutOfCombatRecovery.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-out-of-combat-recovery-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public OutOfCombatRecoverySettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
OutOfCombatRecoverySettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy);
persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public OutOfCombatRecovery.Settings settings() { return parse(effectiveValues); }
public static OutOfCombatRecovery.Settings parse(Map<String, String> values) {
Objects.requireNonNull(values);
try {
long delay = whole(values.get(DELAY), 0, MAX_SECONDS, DELAY);
long interval = whole(values.get(INTERVAL), 1, MAX_SECONDS, INTERVAL);
long amount = whole(values.get(AMOUNT), 0, 1_024, AMOUNT);
return new OutOfCombatRecovery.Settings(Duration.ofSeconds(delay), Duration.ofSeconds(interval),
java.math.BigInteger.valueOf(amount));
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Out-of-combat recovery settings must be whole numbers in range", invalid);
}
}
private static long whole(String raw, long minimum, long maximum, String key) {
var value = new java.math.BigInteger(raw);
long result = value.longValueExact();
if (result < minimum || result > maximum) throw new IllegalArgumentException(key + " is outside its supported range");
return result;
}
public CompletionStage<Update> set(String key, String raw) {
Objects.requireNonNull(key); Objects.requireNonNull(raw);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted;
try { accepted = new java.math.BigInteger(raw).toString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Setting must be a whole number", invalid); }
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Out-of-combat recovery configuration save failed; effective settings unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
}
}
/** Publish only an acknowledged newest persisted snapshot. */
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values();
publishedSequence = update.sequence();
return true;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy", 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);
}
}
@@ -66,6 +66,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.PartySettings partySettings;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
private OutOfCombatRecovery outOfCombatRecovery;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -293,6 +295,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
public void attachOutOfCombatRecovery(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings settings,
OutOfCombatRecovery recovery) {
if (closed || outOfCombatRecoverySettings != null) throw new IllegalStateException("Out-of-combat recovery already attached or command closed");
outOfCombatRecoverySettings = Objects.requireNonNull(settings);
outOfCombatRecovery = Objects.requireNonNull(recovery);
}
public void attachExperienceGrant(AdminExperienceGrant grant) {
if (closed || experienceGrant != null) throw new IllegalStateException("Experience grant already attached or command closed");
@@ -471,6 +479,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)) { sheetResourcePackConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
if (outOfCombatRecoverySettings != null
&& games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys().contains(args[1])) {
outOfCombatRecoveryConfiguration(sender, args); return;
}
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
longbowConfiguration(sender, args); return;
}
@@ -553,6 +565,39 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void outOfCombatRecoveryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + outOfCombatRecoverySettings.value(key)
+ " (applies to future recovery evaluations; the current activity timestamp is retained)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.Update> operation;
try { operation = outOfCombatRecoverySettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Out-of-combat recovery setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
boolean applied = false;
if (failure == null) {
applied = outOfCombatRecoverySettings.publish(saved);
if (applied) outOfCombatRecovery.configure(saved.settings());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) sender.sendMessage("Out-of-combat recovery configuration save failed; effective values are unchanged.");
else if (!applied) sender.sendMessage("Saved request was superseded; effective " + key + "="
+ outOfCombatRecoverySettings.value(key) + ".");
else sender.sendMessage("Saved " + key + "=" + saved.value()
+ "; future evaluations use it and current activity timestamps are retained.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void longbowConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
@@ -980,6 +1025,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
if (args.length == 2 && args[0].equals("config")) {
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
keys.addAll(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
@@ -1006,6 +1052,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)return matching(List.of("false","true"),args[2]);
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
if(sheetResourcePackSettings!=null&&(args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)))return matching(List.of(sheetResourcePackSettings.value(args[1])),args[2]);
if (outOfCombatRecoverySettings != null
&& games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys().contains(args[1])) {
String current = outOfCombatRecoverySettings.value(args[1]);
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 (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]);
@@ -246,6 +246,12 @@ public final class EquipmentSessions implements AutoCloseable {
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
}
/** Current native holder for a still-authorized server-thread action. */
public synchronized Optional<Player> currentPlayer(Active session) {
var attempt = active.get(session.player());
return current(session) && attempt != null ? Optional.of(attempt.player) : Optional.empty();
}
/** Current transferable character snapshot for a still-authorized server-thread action. */
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
var attempt = active.get(session.player());
@@ -0,0 +1,233 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Active-session damage clock and persist-before-native gradual recovery. */
public final class OutOfCombatRecovery implements Listener, AutoCloseable {
public record Settings(Duration delay, Duration interval, BigInteger amount) {
private static final Duration MAX = Duration.ofSeconds(31_536_000);
public Settings {
Objects.requireNonNull(delay); Objects.requireNonNull(interval); Objects.requireNonNull(amount);
if (delay.isNegative() || delay.compareTo(MAX) > 0 || interval.isZero() || interval.isNegative()
|| interval.compareTo(MAX) > 0 || amount.signum() < 0
|| amount.compareTo(BigInteger.valueOf(1_024)) > 0) {
throw new IllegalArgumentException("Invalid out-of-combat recovery settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(30), Duration.ofSeconds(10), BigInteger.ONE);
}
}
public record Failure(UUID character, Throwable cause) { }
private static final int MAXIMUM_SESSION_CHECKS = 64;
private static final class State {
final EquipmentSessions.Active active;
Instant activity;
Instant lastAttempt;
long generation;
Object pending;
State(EquipmentSessions.Active active, Instant activity) { this.active = active; this.activity = activity; }
}
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private final Consumer<Failure> failures;
private final CombatSources sources;
private final Map<UUID, State> states = new HashMap<>();
private final ArrayDeque<UUID> order = new ArrayDeque<>();
private final Set<UUID> queued = new HashSet<>();
private volatile Settings settings;
private org.bukkit.scheduler.BukkitTask task;
private boolean closed;
public OutOfCombatRecovery(Settings settings, CharacterStorage characters, EquipmentSessions sessions, Clock clock,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.clock = Objects.requireNonNull(clock);
this.nextTick = Objects.requireNonNull(nextTick); this.healingEvents = Objects.requireNonNull(healingEvents);
this.nativeLimit = Objects.requireNonNull(nativeLimit); this.failures = Objects.requireNonNull(failures);
sources = new CombatSources(sessions);
}
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
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");
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
/** Bounded active-session evaluation. Exact deadlines are eligible, never replayed in a burst. */
public synchronized void sweep() {
if (closed) return;
Instant now = clock.instant();
var active = new HashMap<UUID, EquipmentSessions.ActiveCharacter>();
for (var snapshot : sessions.activeCharacters()) {
active.put(snapshot.session().player(), snapshot);
var state = states.get(snapshot.session().player());
if (state == null || !state.active.equals(snapshot.session())) {
state = new State(snapshot.session(), now);
states.put(snapshot.session().player(), state);
}
if (queued.add(snapshot.session().player())) order.addLast(snapshot.session().player());
}
for (UUID owner : java.util.List.copyOf(states.keySet())) {
var snapshot = active.get(owner);
if (snapshot == null || !states.get(owner).active.equals(snapshot.session())) remove(owner);
}
int checks = Math.min(MAXIMUM_SESSION_CHECKS, order.size());
for (int checked = 0; checked < checks; checked++) {
UUID owner = order.removeFirst();
var state = states.get(owner);
var snapshot = active.get(owner);
if (state == null || snapshot == null || !state.active.equals(snapshot.session())) {
remove(owner); continue;
}
order.addLast(owner);
evaluate(state, snapshot.record(), now);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public synchronized void acceptedDamage(EntityDamageEvent event) {
if (closed || event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
if (event.getEntity() instanceof Player target) {
sessions.active(target.getUniqueId()).filter(sessions::current).ifPresent(this::reset);
}
var origin = sources.source(event);
if (origin != null && origin.session() != null && sources.current(origin)) {
reset(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character()));
}
}
private void reset(EquipmentSessions.Active active) {
Instant now = clock.instant();
var state = states.get(active.player());
if (state == null || !state.active.equals(active)) {
state = new State(active, now);
states.put(active.player(), state);
if (queued.add(active.player())) order.addLast(active.player());
} else {
state.activity = now;
state.lastAttempt = null;
state.generation++;
}
}
private void evaluate(State state, CharacterRecord record, Instant now) {
if (state.pending != null || !sessions.current(state.active)) return;
var configured = settings;
Instant eligible = state.lastAttempt == null
? state.activity.plus(configured.delay()) : state.lastAttempt.plus(configured.interval());
if (now.isBefore(eligible) || configured.amount().signum() == 0) return;
var player = sessions.currentPlayer(state.active).orElse(null);
if (player == null) return;
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double current = player.getHealth();
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0) return;
if (current >= maximum) {
state.lastAttempt = now; // Full-health checks are not banked for later replay.
return;
}
double amount = configured.amount().doubleValue();
double proposed = Math.min(maximum - current, amount);
if (!Double.isFinite(proposed) || proposed <= 0) return;
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(proposed, Math.min(maximum - current, event.getAmount()));
state.lastAttempt = now;
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
Object token = new Object();
state.pending = token;
long generation = state.generation;
BigInteger expected = record.revision();
BigDecimal target = BigDecimal.valueOf(current + accepted);
BigDecimal limit;
try { limit = nativeLimit.get(); }
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant(); report(record.id(), failure); return; }
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(state, token, generation, expected, current, saved, failure));
} catch (RuntimeException rejected) {
synchronized (OutOfCombatRecovery.this) {
if (state.pending == token) { state.pending = null; state.lastAttempt = clock.instant(); }
}
report(record.id(), rejected);
}
});
}
private synchronized void complete(State state, Object token, long generation, BigInteger expected,
double nativeBefore, java.util.Optional<CharacterRecord> saved, Throwable failure) {
if (state.pending != token) return;
state.pending = null;
if (failure != null || saved == null || saved.isEmpty()) {
state.lastAttempt = clock.instant();
report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
return;
}
var persisted = saved.orElseThrow();
boolean acknowledged = sessions.acknowledgeProgress(state.active, expected, persisted);
if (!acknowledged) {
report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
return;
}
if (closed || generation != state.generation || states.get(state.active.player()) != state
|| !sessions.current(state.active)) return;
var player = sessions.currentPlayer(state.active).orElse(null);
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double maximum = Math.min(attribute.getValue(), persisted.maximumHealth().doubleValue());
double publish = Math.min(maximum, persisted.health().doubleValue());
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) return;
player.setHealth(publish);
}
private void remove(UUID owner) {
states.remove(owner); queued.remove(owner); order.remove(owner);
}
private void report(UUID character, Throwable failure) {
try { failures.accept(new Failure(character, failure)); }
catch (RuntimeException ignored) { /* Diagnostics cannot authorize a retry or native heal. */ }
}
@Override public synchronized void close() {
closed = true;
if (task != null) task.cancel();
states.clear(); order.clear(); queued.clear();
}
}
+6
View File
@@ -10,6 +10,12 @@ party-max-size: 6
sheet-resource-pack-url: ''
sheet-resource-pack-sha1: ''
# Active-session recovery after actual accepted damage has stopped.
# Admin command: /fantasystick config <key> [value]
out-of-combat-healing-delay-seconds: 30
out-of-combat-healing-interval-seconds: 10
out-of-combat-healing-amount: 1
# New-character and transferable fantasy progression defaults.
starting-points: 40
xp-per-level: 100
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(8, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry and session particles");
assertEquals(9, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery and session particles");
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
@@ -74,6 +74,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
assertTrue(fixture.order.indexOf("OutOfCombatRecovery") >= 0);
assertTrue(fixture.order.indexOf("OutOfCombatRecovery") < recipe);
assertTrue(fixture.order.indexOf("RecipeDiscovery") > recipe,
"Discovery must not become active before successful recipe registration");
assertNotNull(fixture.command);
@@ -0,0 +1,231 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.session.OutOfCombatRecovery;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayDeque;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class OutOfCombatRecoveryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void exactDelayRepeatedIntervalsAndLargeClockJumpsPersistOnePulseBeforeNativePublication() {
var f = new Direct();
f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(29)); f.recovery.sweep();
assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep();
assertEquals(1, f.events.size());
assertEquals(EntityRegainHealthEvent.RegainReason.CUSTOM, f.events.getFirst().getRegainReason());
assertEquals(4, f.health.get());
f.runNext();
assertEquals(5, f.health.get());
assertEquals(0, BigDecimal.valueOf(5).compareTo(f.record.get().health()));
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep();
assertEquals(1, f.events.size());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext();
assertEquals(6, f.health.get());
f.clock.advance(Duration.ofSeconds(100)); f.recovery.sweep(); f.runNext();
assertEquals(7, f.health.get(), "A late evaluation applies one pulse rather than replaying missed intervals");
assertEquals(3, f.events.size());
f.health.set(10d);
var revision = f.record.get().revision();
f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep();
assertEquals(3, f.events.size(), "Full health dispatches no native healing event");
assertEquals(revision, f.record.get().revision(), "Full health performs no persistence write");
}
@Test void acceptedEnvironmentalAndDealtDamageResetWhileCancelledAndZeroAttemptsDoNot() {
var f = new Direct(); f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(29));
f.recovery.acceptedDamage(damage(f.player, 2));
f.clock.advance(Duration.ofSeconds(29)); f.recovery.sweep(); assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext(); assertEquals(5, f.health.get());
var noReset = new Direct(); noReset.recovery.sweep(); noReset.clock.advance(Duration.ofSeconds(29));
var cancelled = damage(noReset.player, 2); cancelled.setCancelled(true); noReset.recovery.acceptedDamage(cancelled);
noReset.recovery.acceptedDamage(damage(noReset.player, 0));
noReset.clock.advance(Duration.ofSeconds(1)); noReset.recovery.sweep(); noReset.runNext();
assertEquals(5, noReset.health.get());
var dealt = new Direct();
var target = mock(Player.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
dealt.recovery.sweep(); dealt.clock.advance(Duration.ofSeconds(29));
var source = DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
.withCausingEntity(dealt.player).withDirectEntity(dealt.player).build();
dealt.recovery.acceptedDamage(new EntityDamageByEntityEvent(dealt.player, target,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 2));
dealt.clock.advance(Duration.ofSeconds(29)); dealt.recovery.sweep(); assertTrue(dealt.events.isEmpty());
dealt.clock.advance(Duration.ofSeconds(1)); dealt.recovery.sweep(); dealt.runNext();
assertEquals(5, dealt.health.get(), "Accepted outgoing damage starts a fresh delay for its source character");
}
@Test void cancellationFailureAndDamageDuringPersistenceNeverPublishOrCatchUp() {
var cancelled = new Direct(); cancelled.veto = true; cancelled.recovery.sweep();
cancelled.clock.advance(Duration.ofSeconds(30)); cancelled.recovery.sweep();
assertEquals(1, cancelled.events.size()); assertTrue(cancelled.callbacks.isEmpty()); assertEquals(4, cancelled.health.get());
cancelled.clock.advance(Duration.ofSeconds(9)); cancelled.recovery.sweep(); assertEquals(1, cancelled.events.size());
var failed = new Direct(); failed.automatic = false; failed.recovery.sweep(); failed.clock.advance(Duration.ofSeconds(30));
failed.recovery.sweep(); assertNotNull(failed.pending);
failed.pending.completeExceptionally(new java.io.IOException("simulated")); failed.runNext();
assertEquals(4, failed.health.get());
failed.clock.advance(Duration.ofSeconds(9)); failed.recovery.sweep();
assertEquals(1, failed.events.size(), "Failed persistence does not immediately replay a missed pulse");
var stale = new Direct(); stale.automatic = false; stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(30));
stale.recovery.sweep();
stale.recovery.acceptedDamage(damage(stale.player, 1));
stale.pending.complete(Optional.of(stale.saved(BigDecimal.valueOf(5)))); stale.runNext();
assertEquals(4, stale.health.get(), "Damage during the save invalidates native publication");
stale.clock.advance(Duration.ofSeconds(29)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
}
@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();
assertTrue(f.events.isEmpty()); assertEquals(4, f.health.get());
f.recovery.configure(new OutOfCombatRecovery.Settings(Duration.ofSeconds(60), Duration.ofSeconds(5), BigInteger.ONE));
f.clock.advance(Duration.ofSeconds(19)); f.recovery.sweep(); assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext();
assertEquals(5, f.health.get(), "The new delay evaluates from the retained activity timestamp");
}
@Test void packagedPluginConnectsZeroDelayRecoveryConfigurationAndRestart() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); setHealth(data, a, BigDecimal.valueOf(4));
f.players.put(a.core.id, a.player); f.activate(a);
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
assertEquals(5, a.player.getHealth());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(a.core.record.id()).get().orElseThrow().health()));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("out-of-combat-healing-amount", "out-of-combat-healing-delay-seconds",
"out-of-combat-healing-interval-seconds"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "out-of-combat"}));
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "out-of-combat-healing-amount", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "out-of-combat-healing-amount"});
assertTrue(sender.messages.getLast().contains("=1"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "out-of-combat-healing-amount", "3"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var settings = (games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecoverySettings");
assertEquals("3", settings.value(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.AMOUNT));
}
}
private static EntityDamageEvent damage(Player player, double amount) {
return new EntityDamageEvent(player, EntityDamageEvent.DamageCause.FALL, mock(DamageSource.class), amount);
}
private static void setHealth(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), false, null, null), old.revision());
}
private static final class MutableClock extends Clock {
Instant now = Instant.parse("2026-09-21T00:00:00Z");
void advance(Duration duration) { now = now.plus(duration); }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return now; }
}
private static final class Direct {
final CharacterStorage characters = mock(CharacterStorage.class);
final EquipmentSessions sessions = mock(EquipmentSessions.class);
final Player player = mock(Player.class);
final AttributeInstance maximum = mock(AttributeInstance.class);
final MutableClock clock = new MutableClock();
final AtomicReference<Double> health = new AtomicReference<>(4d);
final AtomicReference<CharacterRecord> record = new AtomicReference<>();
final EquipmentSessions.Active active;
final ArrayDeque<Runnable> callbacks = new ArrayDeque<>();
final java.util.ArrayList<EntityRegainHealthEvent> events = new java.util.ArrayList<>();
OutOfCombatRecovery recovery;
CompletableFuture<Optional<CharacterRecord>> pending;
boolean automatic = true;
boolean veto;
Direct() { this(OutOfCombatRecovery.Settings.defaults()); }
Direct(OutOfCombatRecovery.Settings settings) {
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
record.set(new CharacterRecord(base.id(), base.name(), BigInteger.ONE, base.stats(), base.level(), base.experience(),
base.unspentPoints(), BigDecimal.valueOf(4), base.learned(), base.loadout(), base.cooldowns(),
base.restrictedKills(), false, null, null));
active = new EquipmentSessions.Active(java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), base.id());
when(player.getUniqueId()).thenReturn(active.player()); when(player.getHealth()).thenAnswer(call -> health.get());
doAnswer(call -> { health.set(call.getArgument(0)); return null; }).when(player).setHealth(anyDouble());
when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum); when(maximum.getValue()).thenReturn(10d);
when(sessions.active(active.player())).thenReturn(Optional.of(active));
when(sessions.current(active)).thenReturn(true);
when(sessions.currentPlayer(active)).thenReturn(Optional.of(player));
when(sessions.currentRecord(active)).thenAnswer(call -> Optional.of(record.get()));
when(sessions.activeCharacters()).thenAnswer(call -> List.of(new EquipmentSessions.ActiveCharacter(active, record.get())));
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenAnswer(call -> {
var expected = (BigInteger) call.getArgument(1); var saved = (CharacterRecord) call.getArgument(2);
if (!record.get().revision().equals(expected)) return false;
record.set(saved); return true;
});
when(characters.saveHealth(eq(base.id()), any(BigInteger.class), any(BigDecimal.class), any(BigDecimal.class)))
.thenAnswer(call -> {
var target = (BigDecimal) call.getArgument(2);
pending = new CompletableFuture<>();
if (automatic) pending.complete(Optional.of(saved(target)));
return pending;
});
recovery = new OutOfCombatRecovery(settings, characters, sessions, clock, callbacks::add,
event -> { events.add(event); if (veto) event.setCancelled(true); }, () -> BigDecimal.valueOf(1024), ignored -> {});
}
CharacterRecord saved(BigDecimal target) {
var old = record.get();
return new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), target, old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), false, null, null);
}
void runNext() { assertFalse(callbacks.isEmpty(), "Missing deferred recovery callback"); callbacks.removeFirst().run(); }
}
}
@@ -86,6 +86,10 @@ class DistributionTest {
assertTrue(defaults.contains("admin-revive-selection-timeout-seconds: 60"));
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")) {
assertTrue(defaults.contains(setting), "Missing packaged recovery default: " + setting);
}
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
assertNotNull(entrypoint);
@@ -0,0 +1,89 @@
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.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class OutOfCombatRecoverySettingsTest {
@TempDir Path data;
@Test void parsesReviewedWholeNumberBoundaries() {
var defaults = defaults();
var parsed = OutOfCombatRecoverySettings.parse(defaults);
assertEquals(Duration.ofSeconds(30), parsed.delay());
assertEquals(Duration.ofSeconds(10), parsed.interval());
assertEquals(java.math.BigInteger.ONE, parsed.amount());
var boundaries = new LinkedHashMap<>(defaults);
boundaries.put(OutOfCombatRecoverySettings.DELAY, "0");
boundaries.put(OutOfCombatRecoverySettings.INTERVAL, "31536000");
boundaries.put(OutOfCombatRecoverySettings.AMOUNT, "1024");
parsed = OutOfCombatRecoverySettings.parse(boundaries);
assertEquals(Duration.ZERO, parsed.delay());
assertEquals(Duration.ofSeconds(31_536_000), parsed.interval());
assertEquals(java.math.BigInteger.valueOf(1024), parsed.amount());
for (var invalid : java.util.List.of(
Map.entry(OutOfCombatRecoverySettings.DELAY, "-1"),
Map.entry(OutOfCombatRecoverySettings.DELAY, "31536001"),
Map.entry(OutOfCombatRecoverySettings.DELAY, "1.5"),
Map.entry(OutOfCombatRecoverySettings.INTERVAL, "0"),
Map.entry(OutOfCombatRecoverySettings.INTERVAL, "31536001"),
Map.entry(OutOfCombatRecoverySettings.AMOUNT, "-1"),
Map.entry(OutOfCombatRecoverySettings.AMOUNT, "1025"),
Map.entry(OutOfCombatRecoverySettings.AMOUNT, "1.0"))) {
var values = new LinkedHashMap<>(defaults);
values.put(invalid.getKey(), invalid.getValue());
assertThrows(IllegalArgumentException.class, () -> OutOfCombatRecoverySettings.parse(values), invalid.toString());
}
}
@Test void serializesConcurrentWritesPreservesUnrelatedAndPublishesOnlyAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new OutOfCombatRecoverySettings(data, defaults())) {
var delay = settings.set(OutOfCombatRecoverySettings.DELAY, "45").toCompletableFuture();
var amount = settings.set(OutOfCombatRecoverySettings.AMOUNT, "3").toCompletableFuture();
var d = delay.get(5, TimeUnit.SECONDS); var a = amount.get(5, TimeUnit.SECONDS);
assertEquals("30", settings.value(OutOfCombatRecoverySettings.DELAY));
settings.publish(a); settings.publish(d);
assertEquals("45", settings.value(OutOfCombatRecoverySettings.DELAY));
assertEquals("3", settings.value(OutOfCombatRecoverySettings.AMOUNT));
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated"));
assertEquals("45", String.valueOf(saved.get(OutOfCombatRecoverySettings.DELAY)));
assertEquals("3", String.valueOf(saved.get(OutOfCombatRecoverySettings.AMOUNT)));
}
}
@Test void failedReplacementUnsafeFilesAndCloseRetainEffectiveValues() throws Exception {
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new OutOfCombatRecoverySettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(OutOfCombatRecoverySettings.AMOUNT, "2").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("1", settings.value(OutOfCombatRecoverySettings.AMOUNT));
}
var closed = new OutOfCombatRecoverySettings(data.resolve("closed"), defaults());
closed.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
closed.set(OutOfCombatRecoverySettings.DELAY, "1").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var values = new LinkedHashMap<String, String>();
for (String key : OutOfCombatRecoverySettings.keys()) values.put(key, OutOfCombatRecoverySettings.defaultValue(key));
return values;
}
}