fix(second-wind): prevent stale native healing replay
This commit is contained in:
1 parent
c0b5393646
commit
f2f0dd742f
6 files changed
+635
-32
No files matched your search
@@ -196,6 +196,8 @@ Offhand entry applies actual native maximum health `min(10 + CON, native health
|
||||
|
||||
Ordinary health, food, saturation, exhaustion and health-display state belong to the holder/session, not the character sheet. The session receipt acknowledges this snapshot before native mutation; versioned player PDC records its application/restoration phase. Voluntary exit restores ordinary gear/vitals at the current location, with ordinary health capped by the current legitimate maximum. Character damage is saved on the worker, and the character claim/receipt cannot complete ahead of that acknowledgment. Quit and disable perform the same best-effort cleanup. Failed or uncertain saves retain the receipt and acknowledged character record instead of permitting a stale-health reentry. Missing/ambiguous native proof never authorizes a snapshot heal; process-owned maximum overrides are still retired independently of equipment/client-write failures.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -91,6 +91,7 @@ public final class SecondWind implements AbilityEffect {
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "healing_nonpositive")); execution.finish(); return;
|
||||
}
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(missing, event.getAmount());
|
||||
@@ -98,44 +99,126 @@ public final class SecondWind implements AbilityEffect {
|
||||
execution.resolve("cancelled", Map.of("reason", "native_healing_cancelled_or_nonpositive"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
if (!execution.stillCurrent() || !execution.character().revision().equals(expected)
|
||||
|| !nativeUnchanged(player, current, maximum)) {
|
||||
execution.resolve("interrupted", Map.of("reason", "state_changed_during_healing_event"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
|
||||
// The pinned native pool stores floats. Round down, so publication and the durable
|
||||
// target agree without increasing the event's accepted healing budget by a float ULP.
|
||||
float nativeTarget = (float) Math.min(maximum, current + accepted);
|
||||
if (!Float.isFinite(nativeTarget)) nativeTarget = Float.MAX_VALUE;
|
||||
if (nativeTarget > current + accepted || (double) nativeTarget - current > accepted)
|
||||
nativeTarget = Math.nextDown(nativeTarget);
|
||||
if (nativeTarget <= current) {
|
||||
execution.resolve("cancelled", Map.of("reason", "no_positive_representable_heal"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
BigDecimal target = BigDecimal.valueOf((double) nativeTarget);
|
||||
var callback = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
characters.prepareSecondWindHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (!callback.compareAndSet(false, true)) return;
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
|
||||
execution.resolve(failure != null || saved == null || saved.isEmpty() ? "failed" : "interrupted",
|
||||
Map.of("reason", failure != null ? "health_save_failed" : saved == null || saved.isEmpty()
|
||||
? "health_save_unconfirmed" : "session_changed_before_healing"));
|
||||
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
|
||||
if (failure != null || saved == null || saved.isEmpty()) {
|
||||
execution.resolve("failed", Map.of("reason", failure != null ? "health_save_failed" : "health_save_unconfirmed"));
|
||||
if (execution.stillCurrent()) player.sendMessage("Second Wind health could not be saved; no healing was applied.");
|
||||
return;
|
||||
}
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
|
||||
execution.resolve("interrupted", Map.of("reason", "health_saved_session_changed"));
|
||||
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
|
||||
var pending = saved.orElseThrow(); var persisted = pending.saved();
|
||||
if (!execution.stillCurrent() || !acknowledge(execution.input().session(), expected, persisted)) {
|
||||
execution.resolve("interrupted", Map.of("reason", "health_saved_session_changed_pending_reconciliation"));
|
||||
return; // No current native authority: retain durable proof, never replay the target.
|
||||
}
|
||||
if (!execution.stillCurrent() || !nativeUnchanged(player, current, maximum)) {
|
||||
reconcile(execution, pending, "interrupted", "native_state_changed_before_healing"); return;
|
||||
}
|
||||
double publish = Math.min(maximum, persisted.health().doubleValue());
|
||||
if (!Double.isFinite(publish) || publish <= current || publish - current > accepted) {
|
||||
reconcile(execution, pending, "cancelled", "no_bounded_native_heal_available"); return;
|
||||
}
|
||||
double nativeBefore = player.getHealth();
|
||||
double publish = Math.min(attribute.getValue(), Math.max(nativeBefore, persisted.health().doubleValue()));
|
||||
try { player.setHealth(publish); }
|
||||
catch (RuntimeException nativeFailure) {
|
||||
execution.resolve("failed", Map.of("reason", "native_health_publication_failed"));
|
||||
throw nativeFailure;
|
||||
reconcile(execution, pending, "failed", "native_health_publication_failed"); return;
|
||||
}
|
||||
double nativeAfter = player.getHealth();
|
||||
double restored = nativeAfter - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) {
|
||||
try { execution.acceptedHeal(execution.input().session(), player, persisted, proposed,
|
||||
restored, nativeBefore, nativeAfter, maximum); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot change an applied native heal. */ }
|
||||
execution.resolve("healed", Map.of("reason", "native_self_heal_applied"));
|
||||
execution.report(AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
} else execution.resolve("cancelled", Map.of("reason", "native_heal_not_observed"));
|
||||
double restored = nativeAfter - current;
|
||||
if (!execution.stillCurrent() || Double.compare(nativeAfter, publish) != 0) {
|
||||
reconcile(execution, pending, "interrupted", "native_publication_unconfirmed"); return;
|
||||
}
|
||||
try { execution.acceptedHeal(execution.input().session(), player, persisted, proposed,
|
||||
restored, current, nativeAfter, maximum); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot change an applied native heal. */ }
|
||||
execution.resolve("healed", Map.of("reason", "native_self_heal_applied"));
|
||||
retire(pending, persisted, execution.input().session());
|
||||
execution.report(AbilityReport.Type.HEALING, player, BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
} finally { execution.finish(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
} catch (RuntimeException rejected) {
|
||||
execution.resolve("failed", Map.of("reason", "scheduler_rejected_pending_reconciliation"));
|
||||
execution.finish(); // The durable load barrier remains in place.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean nativeUnchanged(org.bukkit.entity.Player player, double health, double maximum) {
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
return attribute != null && Double.compare(player.getHealth(), health) == 0
|
||||
&& Double.compare(attribute.getValue(), maximum) == 0;
|
||||
}
|
||||
|
||||
private boolean acknowledge(EquipmentSessions.Active active, BigInteger expected,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord saved) {
|
||||
// A pre-existing identical saved target does not advance a revision. It still needs native validation.
|
||||
return sessions.currentRecord(active).filter(saved::equals).isPresent()
|
||||
|| sessions.acknowledgeProgress(active, expected, saved);
|
||||
}
|
||||
|
||||
private void reconcile(AbilityExecution execution, CharacterStorage.PendingHealth pending,
|
||||
String outcome, String reason) {
|
||||
execution.resolve(outcome, Map.of("reason", reason));
|
||||
if (!execution.stillCurrent()) return;
|
||||
var active = execution.input().session(); var player = execution.input().player();
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
double observed = player.getHealth();
|
||||
if (attribute == null || !Double.isFinite(observed) || observed < 0
|
||||
|| !Double.isFinite(attribute.getValue()) || attribute.getValue() <= 0) return;
|
||||
double maximum = attribute.getValue();
|
||||
BigDecimal limit = nativeLimit.get().min(BigDecimal.valueOf(maximum));
|
||||
player.sendMessage("Second Wind was interrupted; no healing was confirmed. Saved health is being reconciled.");
|
||||
var callback = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
characters.reconcileSecondWindHealth(pending, BigDecimal.valueOf(observed), limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (!callback.compareAndSet(false, true)) return;
|
||||
if (failure != null || saved == null || saved.isEmpty()) {
|
||||
reconciliationNotice(active); return;
|
||||
}
|
||||
var settled = saved.orElseThrow();
|
||||
if (!sessions.current(active) || !nativeUnchanged(player, observed, maximum)
|
||||
|| !acknowledge(active, pending.saved().revision(), settled)) {
|
||||
reconciliationNotice(active); return;
|
||||
}
|
||||
retire(pending, settled, active);
|
||||
});
|
||||
} catch (RuntimeException rejected) { /* Retain the load barrier; no native replay. */ }
|
||||
});
|
||||
}
|
||||
|
||||
private void retire(CharacterStorage.PendingHealth pending,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord settled, EquipmentSessions.Active active) {
|
||||
characters.completeSecondWindHealth(pending, settled).whenComplete((completed, failure) -> {
|
||||
if (failure != null || !Boolean.TRUE.equals(completed)) {
|
||||
try { nextTick.accept(() -> reconciliationNotice(active)); }
|
||||
catch (RuntimeException rejected) { /* Unresolved proof still blocks later entry. */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reconciliationNotice(EquipmentSessions.Active active) {
|
||||
sessions.currentPlayer(active).ifPresent(player -> player.sendMessage(
|
||||
"Second Wind health reconciliation is unconfirmed; later character entry remains blocked pending recovery."));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
|
||||
private final java.util.random.RandomGenerator coinRandom;
|
||||
private final CharacterRecoveryStore recoveries;
|
||||
private final SecondWindHealthStore secondWindHealth;
|
||||
private games.dmg.fantasystick.events.CharacterRecoveryEventBridge recoveryEvents;
|
||||
|
||||
public void attachRecoveryEvents(games.dmg.fantasystick.events.CharacterRecoveryEventBridge bridge) {
|
||||
@@ -86,6 +87,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
this.coinRules = Objects.requireNonNull(coinRules);
|
||||
this.coinRandom = Objects.requireNonNull(coinRandom);
|
||||
this.recoveries = new CharacterRecoveryStore(repository.pluginDataDirectory());
|
||||
this.secondWindHealth = new SecondWindHealthStore(repository.pluginDataDirectory());
|
||||
repository.attachMutationUnavailable(recoveries::blocks);
|
||||
}
|
||||
|
||||
@@ -481,6 +483,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");
|
||||
var found = repository.load(id);
|
||||
if (found.isEmpty()) {
|
||||
if (recoveries.find(id).filter(operation -> operation.phase() != CharacterRecoveryStore.Phase.DELIVERED).isPresent())
|
||||
@@ -944,6 +947,77 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
/** Opaque, one-operation authority; only its worker may advance or retire the load barrier. */
|
||||
public static final class PendingHealth {
|
||||
private final UUID operation;
|
||||
private final CharacterRecord saved;
|
||||
private CharacterRecord latest;
|
||||
private boolean completed;
|
||||
private PendingHealth(UUID operation, CharacterRecord saved) {
|
||||
this.operation = operation; this.saved = saved; this.latest = saved;
|
||||
}
|
||||
public CharacterRecord saved() { return saved; }
|
||||
}
|
||||
|
||||
/** Proof is durable before the provisional health target can become visible on disk. */
|
||||
public CompletableFuture<Optional<PendingHealth>> prepareSecondWindHealth(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 (secondWindHealth.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();
|
||||
secondWindHealth.prepare(characterId, operation);
|
||||
if (saved != old) repository.replaceLocked(saved, expectedRevision, true);
|
||||
return Optional.of(new PendingHealth(operation, saved));
|
||||
}));
|
||||
}
|
||||
|
||||
/** Refused native publication reconciles only this operation's exact unchanged revision. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> reconcileSecondWindHealth(PendingHealth pending,
|
||||
java.math.BigDecimal observedHealth, java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(pending);
|
||||
if (observedHealth.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
|
||||
return submit(() -> repository.transaction(() -> {
|
||||
if (pending.completed) return Optional.empty();
|
||||
var id = pending.saved.id();
|
||||
secondWindHealth.verify(id, pending.operation);
|
||||
var found = repository.load(id);
|
||||
if (found.isEmpty() || !found.orElseThrow().equals(pending.latest)) return Optional.empty();
|
||||
var old = found.orElseThrow();
|
||||
var saved = withHealth(old, observedHealth.min(old.maximumHealth()).min(nativeLimit));
|
||||
if (saved != old) repository.replaceLocked(saved, old.revision(), true);
|
||||
pending.latest = saved;
|
||||
return Optional.of(saved);
|
||||
}));
|
||||
}
|
||||
|
||||
/** No newer record or other operation's proof may be cleared by this acknowledgment. */
|
||||
public CompletableFuture<Boolean> completeSecondWindHealth(PendingHealth pending, CharacterRecord settled) {
|
||||
Objects.requireNonNull(pending); Objects.requireNonNull(settled);
|
||||
return submit(() -> repository.transaction(() -> {
|
||||
if (pending.completed || !pending.latest.equals(settled)) return false;
|
||||
secondWindHealth.verify(pending.saved.id(), pending.operation);
|
||||
if (repository.load(pending.saved.id()).filter(settled::equals).isEmpty()) return false;
|
||||
secondWindHealth.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(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),
|
||||
old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
|
||||
}
|
||||
|
||||
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
@@ -961,10 +1035,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var old = found.orElseThrow();
|
||||
if (expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
|
||||
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
var saved = withHealth(old, bounded);
|
||||
if (saved == old) return Optional.of(old);
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
|
||||
@@ -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 SecondWindHealthStore {
|
||||
private final Path directory;
|
||||
|
||||
SecondWindHealthStore(Path pluginDataDirectory) {
|
||||
directory = pluginDataDirectory.resolve("second-wind-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("Second Wind 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("Second Wind 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("Second Wind publication directory is unsafe");
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,245 @@ class SecondWindNativeTest {
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
|
||||
@Test void nativeDamageWhileHealthSaveIsPendingRefusesHealAndSurvivesResume() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withSecondWind(data, a, true, BigDecimal.TEN);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
class AcceptSix implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player) event.setAmount(6);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new AcceptSix(), f.plugin);
|
||||
use(f, a);
|
||||
var cooldown = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(cooldown);
|
||||
cooldown.run();
|
||||
var health = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(health);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var committed = storage.inspect(a.core.record.id()).get().orElseThrow();
|
||||
var deadline = committed.cooldowns().get("second_wind"); assertNotNull(deadline);
|
||||
a.player.setHealth(5); // Native combat changes health without advancing the character revision.
|
||||
health.run();
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
assertEquals(5, a.player.getHealth(), "A stale absolute target must not erase intervening damage");
|
||||
var reconciled = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(0, BigDecimal.valueOf(5).compareTo(reconciled.health()));
|
||||
assertEquals(deadline, reconciled.cooldowns().get("second_wind"));
|
||||
assertFalse(a.messages.stream().anyMatch(message -> message.contains("Second Wind healed")));
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
assertEquals(5, a.player.getHealth(), "Resume cannot replay a refused saved heal");
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("interrupted")).count());
|
||||
}
|
||||
|
||||
@Test void nativeFloatPublicationPersistsAndAuditsOnlyTheRepresentableAcceptedGain() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.TEN);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.healthMutation = () -> a.core.health = (float) a.core.health;
|
||||
class Fractional implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player) event.setAmount(6.1);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Fractional(), f.plugin);
|
||||
use(f, a);
|
||||
for (int i = 0; i < 5; i++) PartyBookNativeTest.drain(f);
|
||||
double gain = a.player.getHealth() - 10;
|
||||
assertTrue(gain > 0 && gain <= 6.1, "The native float step must not exceed the accepted budget");
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(0, BigDecimal.valueOf(a.player.getHealth()).compareTo(storage.find(a.core.record.id()).get().orElseThrow().health()));
|
||||
}
|
||||
var healed = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).toList();
|
||||
assertEquals(1, healed.size());
|
||||
assertTrue(new BigDecimal(healed.getFirst().getAsJsonObject("data").get("actual_amount").getAsString()).compareTo(new BigDecimal("6.1")) <= 0);
|
||||
}
|
||||
|
||||
@Test void nativeHealingWhileSaveIsPendingIsPreservedWithoutSecondWindSuccessOrDuplicateCallback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.TEN);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
var cooldown = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(cooldown); cooldown.run();
|
||||
var health = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(health);
|
||||
a.player.setHealth(12); health.run(); health.run();
|
||||
for (int i = 0; i < 5; i++) PartyBookNativeTest.drain(f);
|
||||
assertEquals(12, a.player.getHealth());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(0, BigDecimal.valueOf(12).compareTo(storage.find(a.core.record.id()).get().orElseThrow().health()));
|
||||
}
|
||||
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
assertEquals(1, events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).count());
|
||||
}
|
||||
|
||||
@Test void reentrantNativeHealingEventCannotPersistOrPublishAgainstItsChangedBaseline() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.TEN);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
class Changed implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player) a.player.setHealth(5);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Changed(), f.plugin); use(f, a);
|
||||
for (int i = 0; i < 3; i++) PartyBookNativeTest.drain(f);
|
||||
assertEquals(5, a.player.getHealth());
|
||||
assertFalse(java.nio.file.Files.exists(healthProof(a)), "Refusal before a health write needs no pending proof");
|
||||
}
|
||||
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
|
||||
@Test void endedSessionKeepsUnresolvedProofAndFreshPluginEntryCannotReplayTheTarget() throws Exception {
|
||||
FantasyStickLifecycleNativeTest.Actor a;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.TEN);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
var cooldown = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(cooldown); cooldown.run();
|
||||
var health = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(health);
|
||||
a.player.setHealth(5);
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); health.run();
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
// Raw inspection fences the owned write; load is intentionally refused by unresolved proof.
|
||||
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.inspect(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(java.nio.file.Files.exists(healthProof(a)));
|
||||
a.core.inventory.setItemInOffHand(sheet);
|
||||
}
|
||||
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
restarted.plugin.setEnabled(true);
|
||||
// A fresh strict actor owns this plugin lifetime; only genuine serialized inventory is transferred.
|
||||
var next = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
next.core.inventory.setContents(a.core.inventory.getContents());
|
||||
restarted.players.put(next.core.id, next.player);
|
||||
double ordinary = next.player.getHealth();
|
||||
restarted.changed(next);
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(restarted);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin, "sessions");
|
||||
assertTrue(sessions.active(next.core.id).isEmpty()); assertEquals(ordinary, next.player.getHealth());
|
||||
assertTrue(java.nio.file.Files.exists(healthProof(a)));
|
||||
}
|
||||
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
|
||||
@Test void unavailableMaximumAndNativeSetterFailureDoNotReplaySavedHealth() throws Exception {
|
||||
for (boolean missingMaximum : new boolean[]{true, false}) {
|
||||
Path root = data.resolve(Boolean.toString(missingMaximum));
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(root)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(root); withSecondWind(root, a, true, BigDecimal.TEN);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
var cooldown = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(cooldown); cooldown.run();
|
||||
var health = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(health);
|
||||
var nativeMaximum = a.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);
|
||||
if (missingMaximum) when(a.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(null);
|
||||
else a.core.beforeHealth = () -> { throw new IllegalStateException("Injected native setter failure"); };
|
||||
health.run();
|
||||
a.core.beforeHealth = () -> {};
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
assertEquals(10, a.player.getHealth());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
if (missingMaximum) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage.find(a.core.record.id()).get());
|
||||
when(a.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(nativeMaximum);
|
||||
} else assertEquals(0, BigDecimal.TEN.compareTo(storage.find(a.core.record.id()).get().orElseThrow().health()));
|
||||
}
|
||||
assertEquals(0, events(root).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void duplicateReconciliationCallbackCannotAnnounceFailureAfterConfirmedCleanup() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.TEN);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
var cooldown = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(cooldown); cooldown.run();
|
||||
var health = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(health);
|
||||
a.player.setHealth(5); health.run();
|
||||
var reconciliation = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(reconciliation);
|
||||
reconciliation.run(); reconciliation.run();
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(a.core.record.id()).get().orElseThrow().health()));
|
||||
assertFalse(a.messages.stream().anyMatch(message -> message.contains("later character entry remains blocked")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReconciliationCannotReplayItsSavedTargetThroughFreshPluginEntry() throws Exception {
|
||||
org.bukkit.inventory.ItemStack[] transferred;
|
||||
java.util.UUID character;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.TEN);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
var cooldown = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(cooldown); cooldown.run();
|
||||
var health = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(health);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
character = a.core.record.id(); storage.close();
|
||||
storage.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
a.player.setHealth(5); health.run(); f.tick();
|
||||
assertEquals(5, a.player.getHealth()); assertTrue(java.nio.file.Files.exists(healthProof(a)));
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("later character entry remains blocked")));
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
// This negative path deliberately stopped the character worker. Fence its actual
|
||||
// remaining receipt work rather than trying to submit another character lookup.
|
||||
for (int i = 0; i < 3; i++) {
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
Runnable task; while ((task = f.tasks.poll()) != null) task.run();
|
||||
}
|
||||
a.core.inventory.setItemInOffHand(sheet);
|
||||
transferred = a.core.inventory.getContents();
|
||||
}
|
||||
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
restarted.plugin.setEnabled(true);
|
||||
var next = new FantasyStickLifecycleNativeTest.Actor(data); next.core.inventory.setContents(transferred);
|
||||
restarted.players.put(next.core.id, next.player);
|
||||
double ordinary = next.player.getHealth(); restarted.changed(next);
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(restarted);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin, "sessions");
|
||||
assertTrue(sessions.active(next.core.id).isEmpty()); assertEquals(ordinary, next.player.getHealth());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin, "characters");
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage.find(character).get());
|
||||
}
|
||||
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
|
||||
@Test void alreadySavedTargetNeedsNoNewRevisionButStillAppliesOnlyAcceptedNativeGainOnce() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.valueOf(11));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); a.player.setHealth(4); use(f, a);
|
||||
var cooldown = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(cooldown); cooldown.run();
|
||||
var health = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(health); health.run(); health.run();
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
assertEquals(11, a.player.getHealth());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(0, BigDecimal.valueOf(11).compareTo(storage.find(a.core.record.id()).get().orElseThrow().health()));
|
||||
assertEquals(1, a.messages.stream().filter(message -> message.contains("Second Wind healed")).count());
|
||||
}
|
||||
assertEquals(1, events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
|
||||
private Path healthProof(FantasyStickLifecycleNativeTest.Actor a) {
|
||||
return data.resolve("second-wind-health").resolve(a.core.record.id() + ".pending");
|
||||
}
|
||||
|
||||
@Test void failedHealthSaveAfterAcknowledgedCooldownNeverClaimsNativeHealing() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -251,8 +490,8 @@ class SecondWindNativeTest {
|
||||
ignored -> {}, () -> BigDecimal.valueOf(1024));
|
||||
when(execution.stillCurrent()).thenReturn(true); when(execution.input()).thenReturn(input);
|
||||
when(execution.character()).thenReturn(record); when(execution.definition()).thenReturn(effect.definition());
|
||||
var failure = new java.util.concurrent.CompletableFuture<java.util.Optional<CharacterRecord>>();
|
||||
when(characters.saveHealth(eq(record.id()), eq(record.revision()), any(), any())).thenReturn(failure);
|
||||
var failure = new java.util.concurrent.CompletableFuture<java.util.Optional<CharacterStorage.PendingHealth>>();
|
||||
when(characters.prepareSecondWindHealth(eq(record.id()), eq(record.revision()), any(), any())).thenReturn(failure);
|
||||
effect.activate(execution); verify(player, never()).setHealth(anyDouble());
|
||||
failure.completeExceptionally(new java.io.IOException("simulated health save failure"));
|
||||
verify(player, never()).setHealth(anyDouble()); verify(execution).finish();
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
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 SecondWindHealthPersistenceTest {
|
||||
@TempDir Path data;
|
||||
private static final BigDecimal LIMIT = BigDecimal.valueOf(1024);
|
||||
|
||||
@Test void provisionalTargetCannotLoadAfterRestartButAcknowledgedReconciliationCan() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareSecondWindHealth(record.id(), record.revision(), BigDecimal.valueOf(16), LIMIT)
|
||||
.join().orElseThrow();
|
||||
assertEquals(0, BigDecimal.valueOf(16).compareTo(storage.inspect(record.id()).join().orElseThrow().health()));
|
||||
assertBlocked(storage, record);
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertBlocked(restarted, record);
|
||||
}
|
||||
var settled = storage.reconcileSecondWindHealth(pending, BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
|
||||
assertEquals(record.cooldowns(), settled.cooldowns()); assertBlocked(storage, record);
|
||||
assertTrue(storage.completeSecondWindHealth(pending, settled).join());
|
||||
assertFalse(storage.completeSecondWindHealth(pending, settled).join(), "Duplicate acknowledgment is not new authority");
|
||||
assertEquals(settled, storage.find(record.id()).join().orElseThrow());
|
||||
var next = storage.prepareSecondWindHealth(record.id(), settled.revision(), BigDecimal.valueOf(11), LIMIT)
|
||||
.join().orElseThrow();
|
||||
assertFalse(storage.completeSecondWindHealth(pending, settled).join(), "Old acknowledgment cannot clear a new operation");
|
||||
assertBlocked(storage, record);
|
||||
assertTrue(storage.completeSecondWindHealth(next, next.saved()).join());
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertEquals(0, BigDecimal.valueOf(11).compareTo(restarted.find(record.id()).join().orElseThrow().health()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void newerWriteIsPreservedAndNeitherReconciliationNorCleanupCanClearItsUncertainProof() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareSecondWindHealth(record.id(), record.revision(), BigDecimal.valueOf(16), LIMIT)
|
||||
.join().orElseThrow();
|
||||
var newer = storage.saveHealth(record.id(), pending.saved().revision(), BigDecimal.valueOf(8), LIMIT)
|
||||
.join().orElseThrow();
|
||||
assertTrue(storage.reconcileSecondWindHealth(pending, BigDecimal.valueOf(5), LIMIT).join().isEmpty());
|
||||
assertFalse(storage.completeSecondWindHealth(pending, pending.saved()).join());
|
||||
assertEquals(newer, storage.inspect(record.id()).join().orElseThrow()); assertBlocked(storage, record);
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertBlocked(restarted, record);
|
||||
assertEquals(0, BigDecimal.valueOf(8).compareTo(restarted.inspect(record.id()).join().orElseThrow().health()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReconciliationRetainsDurableBarrierAndDoesNotClaimAnAcknowledgment() 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.prepareSecondWindHealth(record.id(), record.revision(), BigDecimal.valueOf(16), LIMIT)
|
||||
.join().orElseThrow();
|
||||
fail.set(true);
|
||||
assertThrows(CompletionException.class, () -> storage.reconcileSecondWindHealth(pending, BigDecimal.valueOf(5), LIMIT).join());
|
||||
assertBlocked(storage, record);
|
||||
assertEquals(pending.saved(), storage.inspect(record.id()).join().orElseThrow());
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) { assertBlocked(restarted, record); }
|
||||
}
|
||||
|
||||
@Test void failedProvisionalWriteRetainsProofEvenWhenTheOldRecordIsStillVisible() 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.prepareSecondWindHealth(record.id(), record.revision(), BigDecimal.valueOf(16), 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 malformedOrReplacedProofCannotBeSilentlyDeletedByCleanup() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareSecondWindHealth(record.id(), record.revision(), BigDecimal.valueOf(16), LIMIT)
|
||||
.join().orElseThrow();
|
||||
Path proof = data.resolve("second-wind-health").resolve(record.id() + ".pending");
|
||||
Files.writeString(proof, java.util.UUID.randomUUID().toString());
|
||||
assertThrows(CompletionException.class, () -> storage.completeSecondWindHealth(pending, pending.saved()).join());
|
||||
assertTrue(Files.exists(proof)); assertBlocked(storage, record);
|
||||
Files.writeString(proof, "incomplete");
|
||||
assertThrows(CompletionException.class, () -> storage.reconcileSecondWindHealth(pending, BigDecimal.valueOf(5), LIMIT).join());
|
||||
assertTrue(Files.exists(proof)); assertBlocked(storage, record);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void staleInitialRevisionCannotCreateProofOrChangeHealth() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
assertTrue(storage.prepareSecondWindHealth(record.id(), record.revision().add(BigInteger.ONE), BigDecimal.valueOf(16), LIMIT).join().isEmpty());
|
||||
assertEquals(record, storage.find(record.id()).join().orElseThrow());
|
||||
assertFalse(Files.exists(data.resolve("second-wind-health")));
|
||||
}
|
||||
}
|
||||
|
||||
private YamlCharacterRepository repository(AtomicBoolean fail) {
|
||||
return new YamlCharacterRepository(data, (source, target) -> {
|
||||
if (fail.get()) throw new IOException("Injected health 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("Second Wind"));
|
||||
}
|
||||
|
||||
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));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(12));
|
||||
return new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), BigDecimal.TEN, java.util.Set.of("second_wind"),
|
||||
java.util.List.of("second_wind", "", "", "", "", "", ""), base.passives(),
|
||||
java.util.Map.of("second_wind", java.time.Instant.now().plusSeconds(15)), base.restrictedKills(), false,
|
||||
null, null, base.coins(), base.coinsInitialized(), base.rewardReceipts(), base.pvpCoinEscrow(), base.gear(), base.sheetGeneration());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user