fix(health-steal): bound healing and retain recovery proof
Release / release (push) Successful in 1m10s

This commit was merged in pull request #13.
This commit is contained in:
dmg committed 2026-10-08 15:36:57 +00:00
commit 3a184d5298
7 files changed
+851 -16

No files matched your search

+2
View File
@@ -200,6 +200,8 @@ Ordinary health, food, saturation, exhaustion and health-display state belong to
Second Wind captures native health/maximum and the current character/session revision, honors the native healing event, and refuses changed state rather than replaying an absolute saved target over intervening damage. Its durable target uses a representable native float step no greater than the accepted healing budget. A worker-owned operation token is forced to `<plugin data directory>/second-wind-health/<character UUID>.pending` before the provisional character save. Confirmed native publication or revision-checked, stable-native refusal reconciliation retires only that operation's proof. Failed/ambiguous saves, changed sessions, conflicting newer writes, rejected callbacks or interrupted cleanup leave proof intact; later character loading/entry, including restart, fails closed. Administrative raw inspection remains read-only. There is no automatic proof deletion, stale-heal replay or recovery command for this uncertainty: do not manually delete pending files, downgrade to an older binary that ignores them, or treat record visibility as native-publication proof. Recovery requires a separately reviewed plan. This is not crash-atomic across Minecraft and plugin files, and it does not change Second Wind's formula, configuration, cooldown ownership or no-self-support policy.
Health Steal similarly uses current native health, not a stale saved baseline, and rounds its saved target down to a representable native float within the accepted healing budget. Consumed defeats and repeated publication/reconciliation callbacks cannot replay a heal. Its independent operation token is forced to `<plugin data directory>/health-steal-health/<character UUID>.pending` before a provisional save, including an unchanged saved target. Only confirmed native publication or exact-snapshot, stable-native reconciliation retires that operation's proof. Reconciliation may preserve newer unrelated progress that still carries this operation's health, but never overwrites another owner's newer health. Uncertain writes, session/lifecycle changes, rejected callbacks or conflicting/interrupted cleanup retain proof and block later loading/entry, including after restart, even when a character record is visible through administrative raw inspection. This deliberately trades availability for safety: a character may remain unavailable pending a separately reviewed recovery plan. Do not manually delete proof, downgrade to a binary that ignores it, or replay the consumed defeat. No automatic recovery command or crash-atomic guarantee across Minecraft and plugin files is provided. Successful healing audit records describe observed native healing, not a guarantee that subsequent proof cleanup completed. Configuration and normal heal amounts are unchanged.
An intercepted active-character lethal outcome now creates one durable downed episode rather than immediately recording death. The episode captures its original session, holder, lethal attribution, downed anchor, entry-return location and absolute deadline. While it remains pending, native damage, healing, movement, displacement, inventory/control changes and ordinary gameplay actions are contained; logout and shutdown restore only safe ordinary-holder state while retaining the original countdown. Reconnect rebinds the same proved session at its saved anchor without resetting time. The bounded death menu can accept death, explicitly consume one character-owned Phoenix Down, or wait for the learned, aimed, any-party Revive. Phoenix Down is a persistent Rare Items product with global midnight-UTC stock 0–2, base price 1000 and the shared ±10% pricing policy; a newly delivered product appears only at the next scheduled or authorized restock. Purchase has no immediate effect and the character Inventory instructs use only while downed. An enabled exact-menu choice reserves one item against that episode, competes through the same single-outcome authority, restores full current fantasy health in place and applies the captured `rare-phoenix-down-invulnerability-seconds` deadline (default 5, zero allowed). Logout, restart, waiting, menu reopen, Accept Death, timeout and ordinary Inventory use never consume one automatically. A learned Revive or final death that wins leaves the item intact.
Accept Death and timeout compete with Revive for one persisted outcome. Successful Revive saves full current fantasy health, resumes the same active character in place and uses one absolute configurable protection deadline. Final death records the decision time—or the original expiry for delayed offline reconciliation—then applies frozen PvP reward/coin shares and party consequences exactly once before guarded Tail conversion. Ordinary health is restored before health-scale setters can send or queue client health packets, and return uses only the captured session-entry location. Refused or unavailable destinations retain explicit recovery evidence. New Tails retain the existing signed, authoritative death identity and readable static character history; stale or ambiguous conversion never overwrites another item or automatically reissues a book.
@@ -169,6 +169,13 @@ public final class HealthSteal implements Listener, AutoCloseable {
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
if (events != null) events.refused(audit, healing.isCancelled() ? "native_event_cancelled" : "no_positive_healing_accepted"); return;
}
var validated = sessions.currentRecord(active).orElse(null);
var validatedMaximum = player.getAttribute(Attribute.MAX_HEALTH);
if (!sessions.current(active) || validated == null || !validated.revision().equals(record.revision())
|| Double.compare(player.getHealth(), current) != 0 || validatedMaximum == null
|| Double.compare(Math.min(validatedMaximum.getValue(), validated.maximumHealth().doubleValue()), maximum) != 0) {
if (events != null) events.refused(audit, "native_healing_context_changed"); return;
}
var queue = pending.computeIfAbsent(active.character(), ignored -> new ArrayDeque<>());
if (queue.size() >= MAX_PENDING_PER_CHARACTER) { if (events != null) events.refused(audit, "pending_heal_capacity"); return; }
queue.addLast(new Heal(active, accepted, audit));
@@ -185,46 +192,148 @@ public final class HealthSteal implements Listener, AutoCloseable {
auditFailed(operation, "character_unavailable_or_passive_removed"); finish(character); return;
}
double nativeBefore = player.getHealth();
BigDecimal target = record.health().add(BigDecimal.valueOf(operation.amount()));
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
double maximum = attribute == null ? Double.NaN : Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
if (!Double.isFinite(nativeBefore) || nativeBefore <= 0 || !Double.isFinite(maximum) || nativeBefore >= maximum) {
auditFailed(operation, "no_current_native_healing_capacity"); finish(character); return;
}
BigDecimal limit;
try { limit = nativeLimit.get(); }
catch (RuntimeException failure) { auditFailed(operation, "native_health_limit_unavailable"); report(character, failure); finish(character); return; }
// Native combat, not stale saved health, supplies the baseline. The native pool stores floats:
// persist a representable target rounded down so publication cannot amplify the accepted budget.
double bounded = Math.min(maximum, Math.min(limit.doubleValue(), nativeBefore + operation.amount()));
float nativeTarget = (float) bounded;
if (!Float.isFinite(nativeTarget)) nativeTarget = Float.MAX_VALUE;
if (nativeTarget > bounded || (double) nativeTarget - nativeBefore > operation.amount())
nativeTarget = Math.nextDown(nativeTarget);
if (nativeTarget <= nativeBefore) {
auditFailed(operation, "no_positive_representable_heal"); finish(character); return;
}
BigDecimal target = BigDecimal.valueOf((double) nativeTarget);
BigInteger expected = record.revision();
characters.saveHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
try { nextTick.accept(() -> complete(character, operation, expected, record, nativeBefore, saved, failure)); }
var completed = new java.util.concurrent.atomic.AtomicBoolean();
characters.prepareHealthStealHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
try { nextTick.accept(() -> {
if (completed.compareAndSet(false, true))
complete(character, operation, expected, record, nativeBefore, maximum, saved, failure);
}); }
catch (RuntimeException rejected) { auditFailed(operation, "scheduler_rejected_after_save_uncertain"); report(character, rejected); }
});
}
private void complete(UUID character, Heal operation, BigInteger expected, CharacterRecord before, double nativeBefore,
java.util.Optional<CharacterRecord> saved, Throwable failure) {
double capturedMaximum, java.util.Optional<CharacterStorage.PendingHealthSteal> saved, Throwable failure) {
CharacterRecord repair = null;
try {
if (closed || failure != null || saved == null || saved.isEmpty()) {
auditFailed(operation, closed ? "closed_after_save" : failure != null ? "health_save_failed" : "character_unavailable");
if (failure != null) report(character, failure); return;
}
var record = saved.orElseThrow();
var proof = saved.orElseThrow(); var record = proof.saved();
// An already matching saved target is a successful no-op save, not a new revision.
boolean unchanged = record.equals(before) && sessions.currentRecord(operation.active()).filter(before::equals).isPresent();
if (!sessions.current(operation.active())
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) {
auditFailed(operation, "saved_but_session_publication_refused"); return;
|| !(unchanged || sessions.acknowledgeProgress(operation.active(), expected, record))) {
repair = record; auditFailed(operation, "saved_but_session_publication_refused"); return;
}
var player = sessions.currentPlayer(operation.active()).orElse(null);
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) {
auditFailed(operation, "saved_but_native_health_changed"); return;
repair = record; auditFailed(operation, "saved_but_native_health_changed"); return;
}
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { auditFailed(operation, "saved_but_maximum_health_unavailable"); return; }
if (attribute == null) { repair = record; auditFailed(operation, "saved_but_maximum_health_unavailable"); return; }
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
double publish = Math.min(maximum, record.health().doubleValue());
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) {
if (!Double.isFinite(maximum) || Double.compare(maximum, capturedMaximum) != 0) {
repair = record; auditFailed(operation, "saved_but_maximum_health_changed"); return;
}
double publish = Math.min(maximum, Math.min(record.health().doubleValue(), nativeBefore + operation.amount()));
if (Double.isFinite(publish) && publish > nativeBefore && Double.compare(publish, record.health().doubleValue()) == 0) {
try { player.setHealth(publish); }
catch (RuntimeException nativeFailure) { auditFailed(operation, "native_health_publication_failed"); throw nativeFailure; }
catch (RuntimeException nativeFailure) {
repair = record; auditFailed(operation, "native_health_publication_failed"); throw nativeFailure;
}
// A setter call alone is not proof of the actual native result or retained session authority.
if (Double.compare(player.getHealth(), publish) != 0
|| sessions.currentRecord(operation.active()).filter(record::equals).isEmpty()) {
repair = record; auditFailed(operation, "native_health_publication_unconfirmed"); return;
}
retire(character, proof, record);
if (events != null) events.healed(operation.audit(), before, record, nativeBefore, publish, maximum, operation.amount());
if (feedback != null) feedback.report("Health Steal", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
} else auditFailed(operation, "saved_but_no_positive_native_heal");
} finally { finish(character); }
} else { repair = record; auditFailed(operation, "saved_but_no_positive_native_heal"); }
} finally {
if (repair == null) finish(character); else reconcile(character, operation, saved.orElseThrow(), repair, 2);
}
}
/** Repair only our saved health in the same session. Preserve newer health and all unrelated progress. No native replay. */
private void reconcile(UUID character, Heal operation, CharacterStorage.PendingHealthSteal proof,
CharacterRecord committed, int retries) {
var player = sessions.currentPlayer(operation.active()).orElse(null);
var snapshot = sessions.currentRecord(operation.active()).orElse(null);
if (closed || player == null || snapshot == null || snapshot.revision().compareTo(committed.revision()) < 0
|| snapshot.health().compareTo(committed.health()) != 0) {
finish(character); return;
}
try {
double current = player.getHealth();
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
double maximum = attribute == null ? Double.NaN : Math.min(attribute.getValue(), snapshot.maximumHealth().doubleValue());
if (!Double.isFinite(current) || current < 0 || !Double.isFinite(maximum) || maximum <= 0)
throw new IllegalStateException("Native health unavailable for reconciliation");
var completed = new java.util.concurrent.atomic.AtomicBoolean();
characters.reconcileHealthStealHealth(proof, snapshot, BigDecimal.valueOf(current),
nativeLimit.get().min(BigDecimal.valueOf(maximum)))
.whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> {
if (!completed.compareAndSet(false, true)) return;
boolean retry = false;
try {
if (failure != null) report(character, failure);
else if (saved == null || saved.isEmpty()) {
// A combat reward can advance the revision while retaining our health.
// Retry only a newly acknowledged snapshot, bounded; never retry a heal or an uncertain write.
retry = retries > 0 && sessions.currentRecord(operation.active())
.filter(value -> value.revision().compareTo(snapshot.revision()) > 0
&& value.health().compareTo(committed.health()) == 0).isPresent();
if (!retry) report(character, new IllegalStateException("Health reconciliation revision refused"));
} else if (!closed && sessions.current(operation.active())) {
var record = saved.orElseThrow();
boolean unchanged = record.equals(snapshot)
&& sessions.currentRecord(operation.active()).filter(snapshot::equals).isPresent();
if (Double.compare(record.health().doubleValue(), current) == 0
&& nativeContext(operation, current, maximum)
&& (unchanged || sessions.acknowledgeProgress(operation.active(), snapshot.revision(), record))
&& nativeContext(operation, current, maximum)) retire(character, proof, record);
else report(character, new IllegalStateException("Health reconciliation publication refused"));
}
} finally {
if (retry) reconcile(character, operation, proof, committed, retries - 1); else finish(character);
}
});
} catch (RuntimeException rejected) { report(character, rejected); }
});
} catch (RuntimeException failure) { report(character, failure); finish(character); }
}
private boolean nativeContext(Heal operation, double health, double maximum) {
if (closed || !sessions.current(operation.active())) return false;
var player = sessions.currentPlayer(operation.active()).orElse(null);
var record = sessions.currentRecord(operation.active()).orElse(null);
var attribute = player == null ? null : player.getAttribute(Attribute.MAX_HEALTH);
return record != null && attribute != null && Double.compare(player.getHealth(), health) == 0
&& Double.compare(Math.min(attribute.getValue(), record.maximumHealth().doubleValue()), maximum) == 0;
}
private void retire(UUID character, CharacterStorage.PendingHealthSteal proof, CharacterRecord settled) {
characters.completeHealthStealHealth(proof, settled).whenComplete((completed, failure) -> {
if (failure != null || !Boolean.TRUE.equals(completed))
report(character, failure != null ? failure : new IllegalStateException("Health Steal proof cleanup unconfirmed; later entry remains blocked"));
});
}
private void auditFailed(Heal operation, String reason) { if (events != null) events.failed(operation.audit(), reason); }
private void finish(UUID character) {
@@ -28,6 +28,7 @@ public final class CharacterStorage implements AutoCloseable {
private final java.util.random.RandomGenerator coinRandom;
private final CharacterRecoveryStore recoveries;
private final SecondWindHealthStore secondWindHealth;
private final HealthStealHealthStore healthStealHealth;
private games.dmg.fantasystick.events.CharacterRecoveryEventBridge recoveryEvents;
public void attachRecoveryEvents(games.dmg.fantasystick.events.CharacterRecoveryEventBridge bridge) {
@@ -88,6 +89,7 @@ public final class CharacterStorage implements AutoCloseable {
this.coinRandom = Objects.requireNonNull(coinRandom);
this.recoveries = new CharacterRecoveryStore(repository.pluginDataDirectory());
this.secondWindHealth = new SecondWindHealthStore(repository.pluginDataDirectory());
this.healthStealHealth = new HealthStealHealthStore(repository.pluginDataDirectory());
repository.attachMutationUnavailable(recoveries::blocks);
}
@@ -484,6 +486,7 @@ public final class CharacterStorage implements AutoCloseable {
/** Persist a legacy character's one-time configured migration roll before exposing it. */
private Optional<CharacterRecord> load(UUID id) throws IOException {
if (secondWindHealth.blocks(id)) throw new IOException("Second Wind native publication is unresolved; character entry refused");
if (healthStealHealth.blocks(id)) throw new IOException("Health Steal native publication is unresolved; character entry refused");
var found = repository.load(id);
if (found.isEmpty()) {
if (recoveries.find(id).filter(operation -> operation.phase() != CharacterRecoveryStore.Phase.DELIVERED).isPresent())
@@ -1010,6 +1013,69 @@ public final class CharacterStorage implements AutoCloseable {
}));
}
/** Opaque Health Steal authority, separate from Second Wind and confined to the character worker. */
public static final class PendingHealthSteal {
private final UUID operation;
private final CharacterRecord saved;
private CharacterRecord latest;
private boolean completed;
private PendingHealthSteal(UUID operation, CharacterRecord saved) {
this.operation = operation; this.saved = saved; this.latest = saved;
}
public CharacterRecord saved() { return saved; }
}
public CompletableFuture<Optional<PendingHealthSteal>> prepareHealthStealHealth(UUID characterId,
BigInteger expectedRevision, java.math.BigDecimal health, java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
return submit(() -> repository.transaction(() -> {
if (healthStealHealth.blocks(characterId) || !repository.mutationAvailable(characterId)) return Optional.empty();
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()
|| !found.orElseThrow().revision().equals(expectedRevision)) return Optional.empty();
var old = found.orElseThrow();
var saved = withHealth(old, health.min(old.maximumHealth()).min(nativeLimit));
var operation = UUID.randomUUID();
healthStealHealth.prepare(characterId, operation);
if (saved != old) repository.replaceLocked(saved, expectedRevision, true);
return Optional.of(new PendingHealthSteal(operation, saved));
}));
}
/** Preserve newer unrelated progress only under its exact acknowledged snapshot and our still-owned health. */
public CompletableFuture<Optional<CharacterRecord>> reconcileHealthStealHealth(PendingHealthSteal pending,
CharacterRecord expected, java.math.BigDecimal observedHealth, java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(pending); Objects.requireNonNull(expected);
if (observedHealth.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
return submit(() -> repository.transaction(() -> {
if (pending.completed || !pending.saved.id().equals(expected.id()) || expected.dead()
|| expected.revision().compareTo(pending.latest.revision()) < 0
|| expected.health().compareTo(pending.latest.health()) != 0) return Optional.empty();
var id = pending.saved.id();
healthStealHealth.verify(id, pending.operation);
var found = repository.load(id);
if (found.isEmpty() || !found.orElseThrow().equals(expected)) return Optional.empty();
var saved = withHealth(expected, observedHealth.min(expected.maximumHealth()).min(nativeLimit));
if (saved != expected) repository.replaceLocked(saved, expected.revision(), true);
pending.latest = saved;
return Optional.of(saved);
}));
}
/** A native acknowledgment cannot clear another operation's proof or a newer, unacknowledged record. */
public CompletableFuture<Boolean> completeHealthStealHealth(PendingHealthSteal pending, CharacterRecord settled) {
Objects.requireNonNull(pending); Objects.requireNonNull(settled);
return submit(() -> repository.transaction(() -> {
if (pending.completed || !pending.latest.equals(settled)) return false;
healthStealHealth.verify(pending.saved.id(), pending.operation);
if (repository.load(pending.saved.id()).filter(settled::equals).isEmpty()) return false;
healthStealHealth.complete(pending.saved.id(), pending.operation);
pending.completed = true;
return true;
}));
}
private static CharacterRecord withHealth(CharacterRecord old, java.math.BigDecimal bounded) {
if (old.health().compareTo(bounded) == 0) return old;
return new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
@@ -0,0 +1,71 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.UUID;
/** Unresolved native-publication proof is a load barrier, never permission to replay a heal. */
final class HealthStealHealthStore {
private final Path directory;
HealthStealHealthStore(Path pluginDataDirectory) {
directory = pluginDataDirectory.resolve("health-steal-health").toAbsolutePath();
}
boolean blocks(UUID character) throws IOException {
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return false;
safeDirectory();
// Unknown, malformed, unreadable and symlink proof all remain unresolved.
return !Files.notExists(file(character), LinkOption.NOFOLLOW_LINKS);
}
/** Caller holds the character repository lock through proof and character replacement. */
void prepare(UUID character, UUID operation) throws IOException {
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectory(directory);
DurableFiles.forceDirectory(directory.getParent());
}
safeDirectory();
byte[] bytes = operation.toString().getBytes(StandardCharsets.UTF_8);
try (var channel = FileChannel.open(file(character), StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
DurableFiles.forceDirectory(directory);
}
void verify(UUID character, UUID operation) throws IOException {
safeDirectory();
var path = file(character);
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.size(path) != 36)
throw new IOException("Health Steal publication proof is unsafe or missing");
try (var channel = FileChannel.open(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
var bytes = ByteBuffer.allocate(37);
while (bytes.hasRemaining() && channel.read(bytes) > 0) { /* Bound proof reads. */ }
bytes.flip();
if (!StandardCharsets.UTF_8.decode(bytes).toString().equals(operation.toString()))
throw new IOException("Health Steal publication proof does not match its operation");
}
}
void complete(UUID character, UUID operation) throws IOException {
verify(character, operation);
Files.delete(file(character));
DurableFiles.forceDirectory(directory);
}
private Path file(UUID character) { return directory.resolve(character + ".pending"); }
private void safeDirectory() throws IOException {
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
throw new IOException("Health Steal publication directory is unsafe");
}
}
@@ -92,6 +92,14 @@ class FireballNativeTest {
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
// Health Steal's provisional-health barrier may defer the reward read. Drive the actual
// installed retry callback, without sleeping through or extending Fireball's cooldown.
((games.dmg.fantasystick.party.CombatExperience)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "combatExperience")).retryPending();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
NativeTestAwait.until(f, () -> sessions.currentRecord(sessions.active(a.core.id).orElseThrow())
.orElseThrow().experience().equals(BigInteger.TEN), "Fireball's originating character receives its exact combat XP");
var rewarded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.TEN, rewarded.experience(),
"Native projectile attribution reaches the originating character's combat XP");
@@ -39,6 +39,418 @@ class HealthStealEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredFinalBlowsHealFromNativeHealthDespiteStaleHighLowOrAlreadyMatchingSavedHealth() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
for (String savedHealth : List.of("9", "2", "5", "20")) {
var a = actor(f, new BigDecimal(savedHealth));
double nativeBefore = savedHealth.equals("20") ? 21.5 : 4;
double expected = Math.min(22, nativeBefore + 1);
a.player.setHealth(nativeBefore);
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target); drain(f);
assertEquals(expected, a.player.getHealth(), "Saved " + savedHealth + " cannot set the native heal baseline");
assertEquals(0, BigDecimal.valueOf(expected).compareTo(current(f, a).health()));
var saved = current(f, a);
die(f, target); drain(f); assertEquals(saved, current(f, a));
assertEquals(expected, a.player.getHealth(), "Consumed defeat cannot heal twice");
}
awaitHeals(4); f.plugin.setEnabled(false); f.awaitWorkers();
}
var rows = heals(); assertEquals(4, rows.size());
for (var row : rows) {
var values = row.getAsJsonObject("data");
var before = new BigDecimal(values.get("health_before").getAsString());
var after = new BigDecimal(values.get("health_after").getAsString());
var amount = new BigDecimal(values.get("final_amount").getAsString());
assertEquals(0, after.subtract(before).compareTo(amount));
assertTrue(amount.signum() > 0 && amount.compareTo(BigDecimal.ONE) <= 0);
assertEquals(0, after.compareTo(new BigDecimal(values.get("persisted_health").getAsString())));
}
}
@Test void interveningNativeDamageReconcilesOwnedSaveWithoutHealingOrReplayingDefeat() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9"));
a.player.setHealth(4);
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
var committed = storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(0, new BigDecimal("5").compareTo(committed.health()), "Fence the save before its native callback");
a.player.setHealth(2); drain(f);
assertEquals(2, a.player.getHealth(), "Deferred healing cannot erase intervening damage");
assertEquals(0, new BigDecimal("2").compareTo(current(f, a).health()), "Failed native publication reconciles saved health");
assertEquals(current(f, a), storage(f).find(committed.id()).get(5, TimeUnit.SECONDS).orElseThrow());
var reconciled = current(f, a); die(f, target); drain(f);
assertEquals(reconciled, current(f, a)); assertEquals(2, a.player.getHealth());
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(heals().isEmpty());
assertTrue(records().stream().anyMatch(row -> row.getAsJsonObject("data").has("reason")
&& row.getAsJsonObject("data").get("reason").getAsString().equals("saved_but_native_health_changed")));
}
@Test void reentrantNativeHealingValidationCannotChangeTheCapturedBaselineAndStillApplyHeal() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
var before = current(f, a);
class Gate implements Listener {
@EventHandler public void heal(EntityRegainHealthEvent event) {
if (event.getEntity() == a.player) a.player.setHealth(2);
}
}
f.dispatcher.register(new Gate(), f.plugin);
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target); drain(f);
assertEquals(2, a.player.getHealth(), "Changed native validation cannot authorize an old heal");
assertEquals(before.health(), current(f, a).health(), "Refusal before persistence must not write health");
assertEquals(before.unspentPoints(), current(f, a).unspentPoints());
assertEquals(before.coins(), current(f, a).coins());
var afterReward = current(f, a); // The legitimate combat receipt may advance unrelated progress.
die(f, target); drain(f); assertEquals(afterReward, current(f, a));
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(heals().isEmpty());
}
@Test void queuedRegisteredKillsBoundAcceptedBudgetsAndRefuseCompetingRevisionsWithoutReplay() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
var first = mob(a, EntityType.COW); var second = mob(a, EntityType.BEE);
hit(f, a, first); die(f, first); hit(f, a, second); die(f, second);
// Real combat rewards also advance revisions. Queued healing may refuse that competition,
// but must resolve each consumed defeat once, keep its budget, and never retry a refused heal.
awaitHealthResolutions(f, 2);
assertTrue(a.player.getHealth() == 5 || a.player.getHealth() == 6);
assertEquals(a.player.getHealth(), current(f, a).health().doubleValue());
awaitHeals((int) (a.player.getHealth() - 4));
assertEquals(a.player.getHealth() - 4, heals().size());
var saved = current(f, a); die(f, first); die(f, second); drain(f); assertEquals(saved, current(f, a));
f.plugin.setEnabled(false); f.awaitWorkers();
}
var rows = heals(); assertTrue(rows.size() == 1 || rows.size() == 2);
assertEquals(rows.size(), rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
var baselines = new java.util.HashSet<String>();
for (var row : rows) {
var values = row.getAsJsonObject("data"); baselines.add(values.get("health_before").getAsString());
assertEquals("1.0", values.get("native_accepted_amount").getAsString());
assertEquals("1.0", values.get("final_amount").getAsString());
assertEquals(0, new BigDecimal(values.get("health_after").getAsString())
.compareTo(new BigDecimal(values.get("persisted_health").getAsString())));
}
assertEquals(rows.size() == 1 ? java.util.Set.of("4.0") : java.util.Set.of("4.0", "5.0"), baselines);
}
@Test void nativeAcceptedFractionCannotBeAmplifiedByStaleSavedHealth() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
class Gate implements Listener {
@EventHandler public void heal(EntityRegainHealthEvent event) {
if (event.getEntity() == a.player) event.setAmount(0.25);
}
}
f.dispatcher.register(new Gate(), f.plugin);
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target); drain(f);
assertEquals(4.25, a.player.getHealth()); assertEquals(4.25, current(f, a).health().doubleValue());
awaitHeals(1); f.plugin.setEnabled(false); f.awaitWorkers();
}
var values = heals().getFirst().getAsJsonObject("data");
assertEquals("0.25", values.get("native_accepted_amount").getAsString());
assertEquals("0.25", values.get("final_amount").getAsString());
}
@Test void registeredFractionalHealingUsesRepresentableNativeGainWithinAcceptedBudget() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
int count = 0;
for (double accepted : List.of(0.3, 0.1)) {
var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
// Match the pinned native health pool's float representation, as in Second Wind's fixture.
a.core.healthMutation = () -> a.core.health = (float) a.core.health;
class Gate implements Listener {
@EventHandler public void heal(EntityRegainHealthEvent event) {
if (event.getEntity() == a.player) event.setAmount(accepted);
}
}
f.dispatcher.register(new Gate(), f.plugin);
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
NativeTestAwait.until(f, () -> a.player.getHealth() > 4, "registered fractional Health Steal publication");
double after = a.player.getHealth(); double gain = after - 4;
assertTrue(gain <= accepted, "Native float rounding must not exceed the accepted healing budget: " + gain);
assertEquals(0, BigDecimal.valueOf(after).compareTo(current(f, a).health()));
int expected = ++count;
var rows = NativeTestAwait.auditUntil(f, this::heals, values -> values.size() == expected,
"truthful fractional Health Steal export");
var values = rows.stream().filter(row -> row.get("characterid").getAsString().equals(a.core.record.id().toString()))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(0, BigDecimal.valueOf(4).compareTo(new BigDecimal(values.get("health_before").getAsString())));
// Audit amounts use exact subtraction of their serialized decimal health endpoints.
var exactGain = BigDecimal.valueOf(after).subtract(BigDecimal.valueOf(4));
assertEquals(0, exactGain.compareTo(new BigDecimal(values.get("final_amount").getAsString())));
assertTrue(exactGain.signum() > 0 && exactGain.compareTo(BigDecimal.valueOf(accepted)) <= 0);
assertEquals(0, BigDecimal.valueOf(accepted).compareTo(new BigDecimal(values.get("native_accepted_amount").getAsString())));
assertEquals(0, BigDecimal.valueOf(after).compareTo(new BigDecimal(values.get("persisted_health").getAsString())));
var saved = current(f, a); die(f, target); drain(f);
assertEquals(after, a.player.getHealth()); assertEquals(saved, current(f, a));
}
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertEquals(2, heals().size(), "Consumed defeats cannot produce duplicate healing or success events");
}
@Test void changedMaximumAndUnconfirmedNativeSetterReconcileWithoutSuccessOrReplay() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
for (boolean maximumChanged : List.of(true, false)) {
var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS);
if (maximumChanged) {
var attribute = mock(org.bukkit.attribute.AttributeInstance.class); when(attribute.getValue()).thenReturn(4.5);
doReturn(attribute).when(a.player).getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);
} else {
doAnswer(call -> { a.core.health = 4.5; return null; }).when(a.player).setHealth(5d);
}
drain(f);
double expected = maximumChanged ? 4 : 4.5;
assertEquals(expected, a.player.getHealth()); assertEquals(expected, current(f, a).health().doubleValue());
var saved = current(f, a); die(f, target); drain(f); assertEquals(saved, current(f, a));
// Remove the narrowly injected getter/setter before ordinary lifecycle restoration.
doAnswer(a).when(a.player).getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);
doAnswer(a).when(a.player).setHealth(anyDouble());
}
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(heals().isEmpty(), "A changed or unconfirmed native boundary cannot claim persisted healing");
}
@Test void laterAcknowledgedProgressPreservesNewHealthButReconcilesAnInheritedUnpublishedTarget() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var service = (HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal");
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
for (boolean healthChanged : List.of(true, false)) {
var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
var active = sessions.active(a.core.id).orElseThrow(); var before = current(f, a);
var target = mob(a, EntityType.COW);
// Isolate the acknowledged-progress fault from unrelated reward persistence. The other tests
// exercise registered combat; these are the real owner's validation/save/publication boundaries.
service.damage(damage(a, target)); service.mobDeath(death(target));
var committed = storage(f).inspect(before.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertTrue(sessions.acknowledgeProgress(active, before.revision(), committed));
var newer = healthChanged
? storage(f).saveHealth(before.id(), committed.revision(), new BigDecimal("7"), new BigDecimal("22"))
.get(5, TimeUnit.SECONDS).orElseThrow()
: storage(f).renameCharacter(before.id(), "Later acknowledged name").get(5, TimeUnit.SECONDS).orElseThrow();
assertTrue(sessions.acknowledgeProgress(active, committed.revision(), newer));
if (healthChanged) a.player.setHealth(7);
drain(f);
double expected = healthChanged ? 7 : 4;
assertEquals(expected, a.player.getHealth()); assertEquals(expected, current(f, a).health().doubleValue());
assertEquals(newer.name(), current(f, a).name()); assertEquals(newer.unspentPoints(), current(f, a).unspentPoints());
assertEquals(newer.coins(), current(f, a).coins());
if (healthChanged) assertEquals(newer, current(f, a), "Do not rewrite another owner's newer health");
var saved = current(f, a); service.mobDeath(death(target)); drain(f); assertEquals(saved, current(f, a));
}
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(heals().isEmpty(), "Neither a stale save nor its reconciliation is a successful heal");
}
@Test void duplicateNoOpSaveCallbackCannotReplayConsumedHealingAfterLaterDamage() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("5")); a.player.setHealth(4);
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS);
Runnable publication = null;
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (a.player.getHealth() == 4) {
long remaining = deadline - System.nanoTime(); assertTrue(remaining > 0, "Health Steal publication must complete");
publication = f.tasks.poll(remaining, TimeUnit.NANOSECONDS); assertNotNull(publication); publication.run();
}
assertEquals(5, a.player.getHealth()); assertNotNull(publication);
a.player.setHealth(4); // Native damage need not advance the saved character revision.
publication.run();
assertEquals(4, a.player.getHealth(), "A repeated acknowledged callback cannot replay a consumed defeat's heal");
drain(f); die(f, target); drain(f); assertEquals(4, a.player.getHealth());
NativeTestAwait.auditUntil(f, this::heals, rows -> rows.size() == 1, "Original publication's exact heal audit acknowledgment");
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertEquals(1, heals().size(), "Only the original publication may report healing");
}
@Test void failedReconciliationCannotReplayUnpublishedHealthOnFreshEntry() throws Exception {
configure();
org.bukkit.inventory.ItemStack[] transferred;
UUID character;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
character = a.core.record.id();
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
var store = storage(f);
assertEquals(0, new BigDecimal("5").compareTo(store.inspect(character).get(5, TimeUnit.SECONDS).orElseThrow().health()));
store.close(); store.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
a.player.setHealth(2);
Runnable callback; while ((callback = f.tasks.poll()) != null) callback.run();
assertEquals(2, a.player.getHealth(), "The original native publication must refuse intervening damage");
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
// The failed worker cannot accept a character fence; settle the actual remaining receipt callbacks.
for (int i = 0; i < 3; i++) {
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
while ((callback = f.tasks.poll()) != null) callback.run();
}
a.core.inventory.setItemInOffHand(sheet); transferred = a.core.inventory.getContents();
}
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
restarted.plugin.setEnabled(true); ready(restarted);
var next = new FantasyStickLifecycleNativeTest.Actor(data); next.core.inventory.setContents(transferred);
restarted.players.put(next.core.id, next.player);
assertRefusedEntry(restarted, next);
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage(restarted).find(character).get());
assertTrue(Files.exists(data.resolve("health-steal-health").resolve(character + ".pending")));
}
assertTrue(heals().isEmpty(), "A failed native heal cannot later become successful through reload");
}
@Test void acceptedBudgetBelowOneNativeFloatStepDoesNotWriteOrClaimAHeal() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
a.core.healthMutation = () -> a.core.health = (float) a.core.health;
class Gate implements Listener {
@EventHandler public void heal(EntityRegainHealthEvent event) {
if (event.getEntity() == a.player) event.setAmount(Math.ulp(4f) / 4);
}
}
f.dispatcher.register(new Gate(), f.plugin);
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
NativeTestAwait.auditUntil(f, this::records, rows -> rows.stream().anyMatch(row ->
row.getAsJsonObject("data").has("reason") && row.getAsJsonObject("data").get("reason").getAsString()
.equals("no_positive_representable_heal")), "sub-float healing refusal");
assertEquals(4, a.player.getHealth()); assertEquals(0, new BigDecimal("9").compareTo(current(f, a).health()));
die(f, target); drain(f); assertEquals(4, a.player.getHealth());
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(heals().isEmpty());
}
@Test void lifecycleExitAndTransferAfterSaveNeverPublishOrReplayAnOldNativeHeal() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS);
a.core.inventory.setItemInOffHand(null); f.changed(a); drain(f);
storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals(20, a.player.getHealth(), "Exit restores the fixture's ordinary vitals, not the deferred heal");
var saved = storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(0, new BigDecimal("4").compareTo(saved.health()));
var holder = new FantasyStickLifecycleNativeTest.Actor(data);
holder.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(saved));
f.players.put(holder.core.id, holder.player); assertRefusedEntry(f, holder);
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage(f).find(saved.id()).get());
die(f, target); drain(f);
assertEquals(20, holder.player.getHealth());
assertEquals(saved, storage(f).inspect(saved.id()).get().orElseThrow(), "Consumed defeat cannot alter newer exit health");
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(heals().isEmpty());
}
@Test void registeredRewardRetryPreservesCoinsAfterTheHealthyPublicationBarrierRetires() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("4")); var before = current(f, a);
var service = (HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal");
var rewards = (games.dmg.fantasystick.party.CombatExperience)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "combatExperience");
var target = mob(a, EntityType.ZOMBIE); hit(f, a, target); var defeat = death(target);
// Hold the actual health callback while registered reward delivery encounters its pending proof.
// Redelivering this same native event must not duplicate Health Steal's already-consumed outcome.
service.mobDeath(defeat); storage(f).inspect(before.id()).get(5, TimeUnit.SECONDS);
f.dispatcher.accept(defeat); storage(f).inspect(before.id()).get(5, TimeUnit.SECONDS);
assertEquals(before.coins(), current(f, a).coins());
drain(f); assertEquals(5, a.player.getHealth());
assertEquals(5, storage(f).find(before.id()).get(5, TimeUnit.SECONDS).orElseThrow().health().doubleValue());
rewards.retryPending(); // The existing installed one-second retry, not a new retry policy.
NativeTestAwait.until(f, () -> current(f, a).coins().compareTo(before.coins()) > 0, "The exact pending combat reward settles");
var settled = current(f, a); assertEquals(5, settled.health().doubleValue()); assertEquals(5, a.player.getHealth());
f.dispatcher.accept(defeat); die(f, target); rewards.retryPending(); drain(f);
assertEquals(settled, current(f, a), "Repeated death/retry cannot duplicate XP, coins or health");
NativeTestAwait.auditUntil(f, this::heals, rows -> rows.size() == 1, "Exactly one native heal");
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertEquals(1, heals().size());
}
@Test void rejectedPublicationSchedulerRetainsProofWithoutHealingOrEnablingRestartLoad() throws Exception {
configure(); UUID character;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("4")); character = a.core.record.id();
var service = (HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal");
var scheduling = HealthSteal.class.getDeclaredField("nextTick"); scheduling.setAccessible(true);
scheduling.set(service, (java.util.function.Consumer<Runnable>) callback -> {
throw new java.util.concurrent.RejectedExecutionException("Injected native callback refusal");
});
var target = mob(a, EntityType.COW); service.damage(damage(a, target)); service.mobDeath(death(target));
assertEquals(5, storage(f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow().health().doubleValue());
assertEquals(4, a.player.getHealth()); assertEquals(4, current(f, a).health().doubleValue());
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage(f).find(character).get());
service.mobDeath(death(target)); assertEquals(4, a.player.getHealth());
}
try (var restarted = new CharacterStorage(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data), Clock.systemUTC())) {
assertThrows(java.util.concurrent.ExecutionException.class, () -> restarted.find(character).get());
}
assertTrue(Files.exists(data.resolve("health-steal-health").resolve(character + ".pending")));
assertTrue(heals().isEmpty());
}
@Test void furtherDamageBeforeReconciliationAcknowledgmentKeepsTheLoadBarrier() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
var service = (HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal");
var callbacks = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
var scheduling = HealthSteal.class.getDeclaredField("nextTick"); scheduling.setAccessible(true);
scheduling.set(service, (java.util.function.Consumer<Runnable>) callbacks::add);
var target = mob(a, EntityType.COW); service.damage(damage(a, target)); service.mobDeath(death(target));
storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS); a.player.setHealth(2);
var publication = callbacks.poll(5, TimeUnit.SECONDS); assertNotNull(publication); publication.run();
assertEquals(2, storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().health().doubleValue());
a.player.setHealth(1);
var reconciliation = callbacks.poll(5, TimeUnit.SECONDS); assertNotNull(reconciliation); reconciliation.run();
assertEquals(1, a.player.getHealth(), "Reconciliation never replays native health");
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage(f).find(a.core.record.id()).get());
assertTrue(Files.exists(data.resolve("health-steal-health").resolve(a.core.record.id() + ".pending")));
service.mobDeath(death(target)); drain(f); assertEquals(1, a.player.getHealth());
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(heals().isEmpty());
}
private static void assertRefusedEntry(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
double ordinary = actor.player.getHealth(); f.changed(actor);
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
NativeTestAwait.until(f, () -> {
synchronized (sessions) {
return !((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "pending"))
.containsKey(actor.core.id);
}
}, "Exact character entry attempt must settle, not merely appear inactive while pending");
assertTrue(sessions.active(actor.core.id).isEmpty(), "Unresolved native publication must block entry");
assertEquals(ordinary, actor.player.getHealth());
}
@Test void feedbackOffAndRejectedWriterRecoverExactHealWithoutAnotherHealthOrRewardMutation() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
@@ -100,7 +512,8 @@ class HealthStealEventsNativeTest {
doThrow(new IllegalStateException("native health publication refused")).when(a.player).setHealth(5d);
service.mobDeath(death(nativeFailure));
assertThrows(IllegalStateException.class, () -> drain(f));
assertEquals(4, a.player.getHealth()); assertEquals(0, new BigDecimal("5").compareTo(current(f, a).health()));
drain(f); // Fence the refused native publication's owned health reconciliation.
assertEquals(4, a.player.getHealth()); assertEquals(0, new BigDecimal("4").compareTo(current(f, a).health()));
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(heals().isEmpty());
@@ -209,7 +622,11 @@ class HealthStealEventsNativeTest {
private static LivingEntity mob(FantasyStickLifecycleNativeTest.Actor a, EntityType type) {
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID());
when(target.getType()).thenReturn(type); when(target.getHealth()).thenReturn(1d); when(target.getAbsorptionAmount()).thenReturn(0d);
when(target.getLocation()).thenReturn(new Location(a.world, 1, 64, 0)); return target;
when(target.getLocation()).thenAnswer(ignored -> new Location(a.world, 1, 64, 0));
when(target.getWorld()).thenReturn(a.world);
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return target;
}
private static EntityDamageByEntityEvent damage(FantasyStickLifecycleNativeTest.Actor a, LivingEntity target) {
var source = mock(org.bukkit.damage.DamageSource.class); when(source.getDirectEntity()).thenReturn(a.player);
@@ -242,6 +659,18 @@ class HealthStealEventsNativeTest {
return records().stream().filter(row -> row.get("type").getAsString().equals(LocalEventLog.TYPE_PREFIX + "combat.healed"))
.filter(row -> row.getAsJsonObject("data").get("healing_source").getAsString().equals("health_steal")).toList();
}
private void awaitHealthResolutions(FantasyStickLifecycleNativeTest.Fixture f, int count) throws Exception {
int resolved = 0;
for (int i = 0; i < 500 && resolved != count; i++) {
drain(f);
resolved = (int) records().stream()
.filter(row -> row.get("type").getAsString().equals(LocalEventLog.TYPE_PREFIX + "action.resolved"))
.filter(row -> row.getAsJsonObject("data").has("healing_source")
&& row.getAsJsonObject("data").get("healing_source").getAsString().equals("health_steal")).count();
if (resolved != count) Thread.sleep(10);
}
assertEquals(count, resolved, "Every queued defeat must resolve once, including revision refusals");
}
private void awaitHeals(int count) throws Exception {
for (int i = 0; i < 500 && heals().size() != count; i++) Thread.sleep(10);
assertEquals(count, heals().size());
@@ -0,0 +1,150 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Clock;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class HealthStealHealthPersistenceTest {
@TempDir Path data;
private static final BigDecimal LIMIT = BigDecimal.valueOf(1024);
@Test void provisionalAndReconciledHealthStayBlockedUntilExactAcknowledgmentAcrossRestart() throws Exception {
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
assertBlocked(storage, record);
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) { assertBlocked(restarted, record); }
var settled = storage.reconcileHealthStealHealth(pending, pending.saved(), BigDecimal.valueOf(2), LIMIT).join().orElseThrow();
assertEquals(record.cooldowns(), settled.cooldowns()); assertBlocked(storage, record);
assertFalse(storage.completeHealthStealHealth(pending, pending.saved()).join());
assertTrue(storage.completeHealthStealHealth(pending, settled).join());
assertFalse(storage.completeHealthStealHealth(pending, settled).join());
assertEquals(settled, storage.find(record.id()).join().orElseThrow());
var next = storage.prepareHealthStealHealth(record.id(), settled.revision(), BigDecimal.valueOf(3), LIMIT).join().orElseThrow();
assertFalse(storage.completeHealthStealHealth(pending, settled).join(), "Old acknowledgment cannot clear the new operation");
assertBlocked(storage, record); assertTrue(storage.completeHealthStealHealth(next, next.saved()).join());
}
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
assertEquals(0, BigDecimal.valueOf(3).compareTo(restarted.find(record.id()).join().orElseThrow().health()));
}
}
@Test void inheritedHealthCanBeReconciledWithoutOverwritingNewerProgressButNewerHealthStaysUntouched() throws Exception {
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
var renamed = storage.renameCharacter(record.id(), "Newer name").join().orElseThrow();
assertTrue(storage.reconcileHealthStealHealth(pending, pending.saved(), BigDecimal.valueOf(2), LIMIT).join().isEmpty());
assertFalse(storage.completeHealthStealHealth(pending, pending.saved()).join());
var settled = storage.reconcileHealthStealHealth(pending, renamed, BigDecimal.valueOf(2), LIMIT).join().orElseThrow();
assertEquals(renamed.name(), settled.name()); assertEquals(renamed.cooldowns(), settled.cooldowns());
assertEquals(renamed.coins(), settled.coins()); assertEquals(renamed.unspentPoints(), settled.unspentPoints());
assertTrue(storage.completeHealthStealHealth(pending, settled).join());
var next = storage.prepareHealthStealHealth(record.id(), settled.revision(), BigDecimal.valueOf(3), LIMIT).join().orElseThrow();
var newerHealth = storage.saveHealth(record.id(), next.saved().revision(), BigDecimal.valueOf(7), LIMIT).join().orElseThrow();
assertTrue(storage.reconcileHealthStealHealth(next, newerHealth, BigDecimal.valueOf(2), LIMIT).join().isEmpty());
assertFalse(storage.completeHealthStealHealth(next, newerHealth).join());
assertEquals(newerHealth, storage.inspect(record.id()).join().orElseThrow()); assertBlocked(storage, record);
}
}
@Test void failedReconciliationRetainsProofAndCannotAuthorizeRestartLoading() throws Exception {
var fail = new AtomicBoolean(); var repository = repository(fail); var record = character(); repository.create(record);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
fail.set(true);
assertThrows(CompletionException.class, () -> storage.reconcileHealthStealHealth(pending, pending.saved(), BigDecimal.valueOf(2), LIMIT).join());
assertEquals(pending.saved(), storage.inspect(record.id()).join().orElseThrow()); assertBlocked(storage, record);
}
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) { assertBlocked(restarted, record); }
}
@Test void uncertainProvisionalSaveRetainsProofEvenWhenTheOriginalRecordRemains() throws Exception {
var fail = new AtomicBoolean(); var repository = repository(fail); var record = character(); repository.create(record);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
fail.set(true);
assertThrows(CompletionException.class, () -> storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join());
assertEquals(record, storage.inspect(record.id()).join().orElseThrow()); assertBlocked(storage, record);
}
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) { assertBlocked(restarted, record); }
}
@Test void malformedReplacedOrMissingProofCannotAuthorizeCleanupOrReconciliation() throws Exception {
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
var proof = data.resolve("health-steal-health").resolve(record.id() + ".pending");
for (String invalid : java.util.List.of(java.util.UUID.randomUUID().toString(), "incomplete")) {
Files.writeString(proof, invalid);
assertThrows(CompletionException.class, () -> storage.completeHealthStealHealth(pending, pending.saved()).join());
assertThrows(CompletionException.class, () -> storage.reconcileHealthStealHealth(pending, pending.saved(), BigDecimal.valueOf(2), LIMIT).join());
assertEquals(invalid, Files.readString(proof)); assertBlocked(storage, record);
}
Files.delete(proof); // Adversarial test only; manual removal is not a supported recovery operation.
assertThrows(CompletionException.class, () -> storage.completeHealthStealHealth(pending, pending.saved()).join());
}
}
@Test void symlinkProofAndUnsafeDirectoryRefuseWithoutTouchingExternalContents() throws Exception {
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
var outside = data.resolve("outside-proof"); Files.writeString(outside, "must remain unchanged");
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
var directory = data.resolve("health-steal-health"); var proof = directory.resolve(record.id() + ".pending");
Files.delete(proof); Files.createSymbolicLink(proof, outside);
assertBlocked(storage, record);
assertThrows(CompletionException.class, () -> storage.completeHealthStealHealth(pending, pending.saved()).join());
assertTrue(Files.isSymbolicLink(proof)); assertEquals("must remain unchanged", Files.readString(outside));
Files.delete(proof); Files.delete(directory); Files.writeString(directory, "not a proof directory");
assertBlocked(storage, record);
assertThrows(CompletionException.class, () -> storage.prepareHealthStealHealth(record.id(), pending.saved().revision(), BigDecimal.valueOf(6), LIMIT).join());
assertEquals("not a proof directory", Files.readString(directory));
assertEquals(pending.saved(), storage.inspect(record.id()).join().orElseThrow());
}
}
@Test void staleRevisionCannotPrepareProofAndUnchangedSavedTargetStillRequiresAcknowledgment() throws Exception {
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
assertTrue(storage.prepareHealthStealHealth(record.id(), record.revision().add(BigInteger.ONE), record.health(), LIMIT).join().isEmpty());
assertFalse(Files.exists(data.resolve("health-steal-health")));
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), record.health(), LIMIT).join().orElseThrow();
assertEquals(record, pending.saved()); assertBlocked(storage, record);
assertTrue(storage.prepareHealthStealHealth(record.id(), record.revision(), record.health(), LIMIT).join().isEmpty());
assertTrue(storage.completeHealthStealHealth(pending, record).join());
assertEquals(record, storage.find(record.id()).join().orElseThrow());
}
}
private YamlCharacterRepository repository(AtomicBoolean fail) {
return new YamlCharacterRepository(data, (source, target) -> {
if (fail.get()) throw new IOException("Injected Health Steal replacement failure");
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
});
}
private static void assertBlocked(CharacterStorage storage, CharacterRecord record) {
var failure = assertThrows(CompletionException.class, () -> storage.find(record.id()).join());
assertInstanceOf(IOException.class, failure.getCause()); assertTrue(failure.getCause().getMessage().contains("Health Steal"));
}
private static CharacterRecord character() {
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats());
stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(12));
return new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(), base.unspentPoints(),
BigDecimal.valueOf(4), base.learned(), base.loadout(), java.util.List.of("health_steal", "", ""),
java.util.Map.of("fireball", java.time.Instant.now().plusSeconds(15)), base.restrictedKills(), false, null, null,
base.coins(), base.coinsInitialized(), base.rewardReceipts(), base.pvpCoinEscrow(), base.gear(), base.sheetGeneration());
}
}