Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc8fe44033 |
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user