Compare commits

...
6 Commits
Author SHA1 Message Date
dmg 7b6ea939a5 Merge pull request 'fix(second-wind): prevent stale native healing replay' (#10) from us-031-recover-with-second-wind into main
Release / release (push) Successful in 1m25s
2026-10-08 12:55:10 +00:00
dmg f2f0dd742f fix(second-wind): prevent stale native healing replay
CI / build (push) Successful in 56s
CI / build (pull_request) Successful in 49s
2026-10-08 08:52:54 -04:00
dmg c0b5393646 test(native): await operation and audit acknowledgments
Release / release (push) Successful in 1m9s
2026-10-08 12:22:53 +00:00
dmg cd4d5a1360 test(native): await operation and audit acknowledgments
CI / build (push) Successful in 49s
CI / build (pull_request) Successful in 1m1s
2026-10-07 22:27:20 -04:00
dmg 6563f61f19 fix(ci): package locally verified source without remote tests
Release / release (push) Successful in 1m23s
FS-BUILD-02: remote packaging-only workflows, mandatory full local exact-tree proof retained. Tested tree 8b0331bd44ae3baa484c30366db13c6f48ba5bb8; all 2,361 local tests pass, 647 inputs unchanged. Current-head CI3169/3170 passed. No GitOps/deployment or overall security clearance.
2026-10-08 02:19:42 +00:00
dmg def83dcb32 fix(ci): package locally verified source without remote tests
CI / build (pull_request) Successful in 2m45s
CI / build (push) Successful in 46s
2026-10-07 22:15:19 -04:00
19 changed files with 1028 additions and 136 deletions

No files matched your search

+3 -2
View File
@@ -43,8 +43,9 @@ jobs:
run: |
npx -y -p @commitlint/cli -p @commitlint/config-conventional \
commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --extends @commitlint/config-conventional
- name: Build and test
run: ./gradlew clean check jar
# Full clean checks are required locally for this exact candidate before integration.
- name: Compile and package
run: ./gradlew clean jar
- name: Upload development artifact
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
+8 -4
View File
@@ -39,8 +39,9 @@ jobs:
with:
node-version: 20
cache: npm
- name: Build and test before release
run: ./gradlew clean check jar
# Full clean checks are required locally for this exact candidate before integration.
- name: Compile and package before release
run: ./gradlew clean jar
- name: Capture previous tag
id: previous
run: |
@@ -86,11 +87,14 @@ jobs:
else
printf 'created=false\n' >> "$GITHUB_OUTPUT"
fi
- name: Verify versioned plugin and release tooling
- name: Package versioned plugin and compile release tooling
if: steps.release.outputs.created == 'true'
env:
VERSION: ${{ steps.release.outputs.version }}
run: ./gradlew clean check jar -PreleaseVersion="$VERSION"
run: |
./gradlew clean jar -PreleaseVersion="$VERSION"
npm ci --ignore-scripts --no-audit --no-fund
npm run build
- name: Upload release workflow artifact
if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
+16 -1
View File
@@ -16,6 +16,19 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
./gradlew check jar -PreleaseVersion=0.1.0
```
The full local suite remains mandatory before integrating a candidate. Retain passing reports against its exact Git tree and unchanged verification inputs; changes require renewed verification. This requirement is not satisfied by a packaging-only CI pass.
For packaging without running tests:
```sh
./gradlew clean jar
./gradlew clean jar -PreleaseVersion=0.1.0
npm ci --ignore-scripts --no-audit --no-fund
npm run build
```
`npm run build` compiles the TypeScript publication tools without executing tests; `npm test` still runs the complete tooling suite.
`check` runs Java packaging tests, TypeScript release-client/credential-helper tests, and a native compatibility test against the checksum-verified Purpur runtime. Native preparation downloads and patches the declared runtime and loads vanilla registries/components **without starting a listening server or creating a gameplay world**. The item tests decode signed written books with Minecraft's real codec, without a plugin instance or YAML lookup. A clean build repeats that preparation. Dependencies and runtime downloads require network access.
Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, tests, credentials, and Node tooling are not bundled in the plugin. Runtime identity is `FantasyStick`, entrypoint `games.dmg.fantasystick.FantasyStickPlugin`, data folder `FantasyStick`, namespace `fantasystick`. Startup registers the manual crafting controller and automated-Crafter guard before the character-sheet recipe. Equipment sessions and permission-gated administration are registered; spell execution remains absent.
@@ -183,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.
@@ -213,7 +228,7 @@ Automated party tests exercise actual packaged listeners, native book custom-cli
## CI and publication
Gitea CI verifies pushes/PRs; main pushes run semantic-release and can publish immutable versioned JARs. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The operator has provisioned `RELEASE_TOKEN` and the bootstrap is pushed. Do not infer successful CI, publication or credential validity from the secret name alone; record actual exact-commit workflow and artifact results in the canonical wiki.
Gitea CI compiles/packages pushes and PRs without rerunning test suites; main pushes run semantic-release, package the exact version, compile publication tooling and can publish immutable versioned JARs. Complete local test proof for the exact candidate remains required before integration; verify that the fetched merge tree equals the locally tested tree. Remote success is packaging/publication evidence, not remote test evidence. This temporary separation does not delete tests, alter their limits, waive explicit CI-test criteria, or clear security findings. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The operator has provisioned `RELEASE_TOKEN` and the bootstrap is pushed. Do not infer successful CI, publication or credential validity from the secret name alone; record actual exact-commit workflow and artifact results in the canonical wiki.
The release credential must be a repository-write credential authorized for `dmg/purpur-fantasy-stick`. Provision it through the approved protected-file/stdin workflow; never paste it into chat, commit it, or supply it as a command argument.
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"engines": { "node": ">=20" },
"scripts": {
"build": "tsc",
"test": "rm -rf build/release-tools && tsc && node --test build/release-tools/*.test.js"
},
"devDependencies": {
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
// Execute the actual checked-in run blocks against strict external build-tool ports.
// Real Gradle packaging is verified separately on the final candidate; these ports prove wiring.
function buildCommands(workflow: string): string[] {
const lines = readFileSync(`.gitea/workflows/${workflow}`, "utf8").split("\n");
const commands: string[] = [];
for (let i = 0; i < lines.length; i++) {
const match = /^ {8}run: (.+)$/.exec(lines[i]!);
if (!match) continue;
let command = match[1]!;
if (command === "|") {
const body: string[] = [];
while (i + 1 < lines.length && /^ {10}/.test(lines[i + 1]!)) body.push(lines[++i]!.slice(10));
command = body.join("\n");
}
if (command.includes("./gradlew")) commands.push(command);
}
return commands;
}
function fixture() {
const root = mkdtempSync(join(tmpdir(), "fantasy-packaging-port-"));
mkdirSync(join(root, "bin"));
writeFileSync(join(root, "gradlew"), `#!/bin/sh
set -eu
version=0.1.0-SNAPSHOT
printf 'gradle %s\\n' "$*" >> "$CALLS"
if [ "$FAIL_PACKAGE" = true ]; then exit 87; fi
for arg do
case "$arg" in
clean|jar) ;;
-PreleaseVersion=*) version="\${arg#-PreleaseVersion=}" ;;
*) printf 'Unexpected task: %s\\n' "$arg" >&2; exit 91 ;;
esac
done
mkdir -p build/libs build/distributions
printf 'external packaging port\\n' > "build/libs/purpur-fantasy-stick-$version.jar"
printf 'external resource-pack port\\n' > "build/distributions/purpur-fantasy-stick-$version-resource-pack.zip"
`, { mode: 0o700 });
writeFileSync(join(root, "bin/npm"), `#!/bin/sh
set -eu
printf 'npm %s\\n' "$*" >> "$CALLS"
case "$*" in
'ci --ignore-scripts --no-audit --no-fund') ;;
'run build')
[ "$BUILD_SCRIPT" = tsc ] || { printf 'Missing compile-only release-tool script\\n' >&2; exit 92; }
mkdir -p build/release-tools
printf 'external TypeScript compiler port\\n' > build/release-tools/publish-release.js
;;
*) printf 'Unexpected npm command: %s\\n' "$*" >&2; exit 93 ;;
esac
`, { mode: 0o700 });
const metadata = JSON.parse(readFileSync("package.json", "utf8")) as { scripts: Record<string, string> };
return {
root,
run(command: string, failPackage = false) {
return spawnSync("/bin/sh", ["-eu", "-c", command], {
cwd: root, encoding: "utf8", timeout: 10_000,
env: { PATH: `${join(root, "bin")}:/usr/bin:/bin`, CALLS: join(root, "calls"), VERSION: "3.2.1",
BUILD_SCRIPT: metadata.scripts.build ?? "", FAIL_PACKAGE: String(failPackage) },
});
},
close() { rmSync(root, { recursive: true, force: true }); },
};
}
test("CI packages its development artifact without dispatching verification tasks", () => {
const commands = buildCommands("ci.yml");
assert.equal(commands.length, 1);
const port = fixture();
try {
const result = port.run(commands[0]!);
assert.equal(result.status, 0, result.stderr);
assert.ok(existsSync(join(port.root, "build/libs/purpur-fantasy-stick-0.1.0-SNAPSHOT.jar")));
assert.equal(readFileSync(join(port.root, "calls"), "utf8"), "gradle clean jar\n");
} finally { port.close(); }
});
test("release packages its exact version and compiles publication tools without running suites", () => {
const commands = buildCommands("release.yml");
assert.equal(commands.length, 2);
const port = fixture();
try {
for (const command of commands) {
const result = port.run(command);
assert.equal(result.status, 0, result.stderr);
}
assert.ok(existsSync(join(port.root, "build/libs/purpur-fantasy-stick-3.2.1.jar")));
assert.ok(existsSync(join(port.root, "build/distributions/purpur-fantasy-stick-3.2.1-resource-pack.zip")));
assert.ok(existsSync(join(port.root, "build/release-tools/publish-release.js")));
assert.equal(readFileSync(join(port.root, "calls"), "utf8"),
"gradle clean jar\ngradle clean jar -PreleaseVersion=3.2.1\nnpm ci --ignore-scripts --no-audit --no-fund\nnpm run build\n");
} finally { port.close(); }
});
test("failed versioned packaging stops before preparing publication tools", () => {
const port = fixture();
try {
const result = port.run(buildCommands("release.yml")[1]!, true);
assert.equal(result.status, 87);
assert.equal(readFileSync(join(port.root, "calls"), "utf8"), "gradle clean jar -PreleaseVersion=3.2.1\n");
assert.ok(!existsSync(join(port.root, "build/release-tools/publish-release.js")));
} finally { port.close(); }
});
@@ -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");
}
}
@@ -40,7 +40,14 @@ class AdminActionConfigurationCatalogNativeTest {
private static JsonObject values(JsonObject row) { return row.getAsJsonObject("data"); }
private static String outcome(JsonObject row) { return values(row).get("outcome").getAsString(); }
private List<JsonObject> actions() throws Exception { return AdminActionEntryNativeTest.rows(data); }
private List<JsonObject> actions(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
return NativeTestAwait.readAudit(f, () -> AdminActionEntryNativeTest.rows(data));
}
private List<JsonObject> awaitAction(FantasyStickLifecycleNativeTest.Fixture f, int before) throws Exception {
return NativeTestAwait.auditUntil(f, () -> since(AdminActionEntryNativeTest.rows(data), before),
rows -> rows.size() >= 3 && "result".equals(values(rows.getLast()).get("stage").getAsString())
&& !"pending".equals(outcome(rows.getLast())), "terminal configuration action export");
}
private List<JsonObject> domain(String suffix) throws Exception {
return ReskillEventsNativeTest.records(data).stream()
.filter(row -> row.get("type").getAsString().equals("games.dmg.minecraft.fantasystick." + suffix)).toList();
@@ -75,11 +82,12 @@ class AdminActionConfigurationCatalogNativeTest {
var block = new AdminActionEntryNativeTest.Entry(fixture, "command_block", true);
assertEquals(suggested, console.map.tabComplete(console.sender, "fantasystick:fstickadmin config "));
assertEquals(suggested, block.map.tabComplete(block.sender, "fstickadmin config "));
assertTrue(actions().isEmpty(), "catalog and value completion must never be audited");
assertTrue(actions(f).isEmpty(), "catalog and value completion must never be audited");
var inventory = fixture.actor.core.snapshot();
var configuration = Files.readAllBytes(data.resolve("config.yml"));
var actors = List.of(player, console, block);
var checks = new java.util.ArrayList<org.junit.jupiter.api.function.Executable>();
var allRows = new java.util.ArrayList<JsonObject>();
for (int family = 0; family < FAMILIES.size(); family++) {
for (var key : FAMILIES.get(family)) {
// Every key is exercised; each family also has a non-player sender, without tripling all reads.
@@ -90,18 +98,21 @@ class AdminActionConfigurationCatalogNativeTest {
assertNotNull(valueSuggestions, "real current/default completion for " + key);
var current = readValue(data, key);
if (current == null && !valueSuggestions.isEmpty()) current = valueSuggestions.getFirst();
var before = actions().size(); var domainBefore = domain("config.read").size();
var before = actions(f).size();
var domainBefore = NativeTestAwait.readAudit(f, () -> domain("config.read")).size();
var messages = entry.messages().size();
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config " + key), key);
assertTrue(entry.messages().size() > messages, "real owner must respond to " + key);
assertTrue(entry.messages().subList(messages, entry.messages().size()).stream()
.anyMatch(message -> message.contains(key + "=")), "key-specific read, not generic usage: " + key);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var rows = since(actions(), before);
var rows = awaitAction(f, before);
allRows.addAll(rows);
var expectedCurrent = current;
var capturedReads = since(domain("config.read"), domainBefore);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var stableCount = actions().size();
var capturedReads = key.equals(FrostShardSettings.RANGE)
? NativeTestAwait.auditUntil(f, () -> since(domain("config.read"), domainBefore),
reads -> !reads.isEmpty(), "authoritative Frost configuration read")
: NativeTestAwait.readAudit(f, () -> since(domain("config.read"), domainBefore));
var stableCount = actions(f).size();
checks.add(() -> {
sequence(rows, kind, "completed");
assertEquals("read_completed", values(rows.getLast()).get("reason").getAsString(), key);
@@ -129,6 +140,8 @@ class AdminActionConfigurationCatalogNativeTest {
});
}
}
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(allRows, actions(f), "no delayed duplicate or extra action after the complete catalog");
assertAll("actual completed configuration key reads", checks);
}
}
@@ -166,6 +179,7 @@ class AdminActionConfigurationCatalogNativeTest {
var suggestions = player.map.tabComplete(player.sender, "fstickadmin config ");
var actors = List.of(player, console, block);
var checks = new java.util.ArrayList<org.junit.jupiter.api.function.Executable>();
var allRows = new java.util.ArrayList<JsonObject>();
for (int i = 0; i < WRITES.size(); i++) {
var pair = WRITES.get(i); var key = pair[0]; var requested = pair[1];
assertTrue(suggestions.contains(key), "write must use an exposed active key: " + key);
@@ -175,15 +189,19 @@ class AdminActionConfigurationCatalogNativeTest {
var old = readValue(data, key);
if (old == null) { assertFalse(values.isEmpty(), key); old = values.getFirst(); }
assertNotEquals(old, requested, "write must actually change effective value: " + key);
var before = actions().size(); var messages = entry.messages().size();
var before = actions(f).size(); var messages = entry.messages().size();
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin config " + key + " " + requested));
awaitWrite(f, entry, key, requested, messages);
assertEquals(requested, readValue(data, key), "actual durable save: " + key);
assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin config " + key + " ").contains(requested),
"native effective setting must change, not just the file: " + key);
assertTrue(entry.messages().size() > messages, "native owner feedback: " + key);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var rows = since(actions(), before);
var rows = awaitAction(f, before);
allRows.addAll(rows);
if (key.equals(FrostShardSettings.RANGE))
NativeTestAwait.auditUntil(f, () -> domain("config.changed"),
changes -> changes.stream().anyMatch(row -> values(row).get("key").getAsString().equals(key)),
"authoritative Frost configuration write");
var expectedBefore = old;
checks.add(() -> {
sequence(rows, kind, "completed");
@@ -197,7 +215,7 @@ class AdminActionConfigurationCatalogNativeTest {
assertEquals(requested, result.get("after").getAsString(), key);
assertEquals("untouched", readValue(data, "retained"));
if (key.equals(FrostShardSettings.RANGE)) {
var changed = domain("config.changed").stream()
var changed = NativeTestAwait.readAudit(f, () -> domain("config.changed")).stream()
.filter(row -> values(row).get("key").getAsString().equals(key)).toList();
assertEquals(1, changed.size(), "no replacement or duplicate authoritative Frost event");
assertEquals(values(changed.getFirst()).get("action_id").getAsString(),
@@ -205,6 +223,8 @@ class AdminActionConfigurationCatalogNativeTest {
}
});
}
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(allRows, actions(f), "no delayed duplicate or extra action after all writes");
assertAll("actual acknowledged configuration owner writes", checks);
}
}
@@ -272,17 +292,17 @@ class AdminActionConfigurationCatalogNativeTest {
@Test void unknownKeyAndMalformedTypedValuesRefuseWithoutSavingOrLeakingFreeformInput() throws Exception {
seed();
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
for (var command : List.of("config unregistered-password SECRET_VALUE",
"config " + FrostShardSettings.RANGE + " https://secret.invalid/token",
"config " + RoleplaySettings.KEY + " SECRET_VALUE")) {
var config = Files.readAllBytes(data.resolve("config.yml"));
var inventory = fixture.actor.core.snapshot(); var before = actions().size();
var inventory = fixture.actor.core.snapshot(); var before = actions(f).size();
var messages = entry.messages().size();
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + command));
assertTrue(entry.messages().size() > messages, "native validation must run");
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
var rows = since(actions(), before);
var rows = awaitAction(f, before);
sequence(rows, "player", "refused");
assertFalse(rows.toString().contains("SECRET_VALUE"));
assertFalse(rows.toString().contains("secret.invalid"));
@@ -451,10 +451,7 @@ class AdminActionIdentityAndEquipmentNativeTest {
}
private static <T> T observeExport(FantasyStickLifecycleNativeTest.Fixture f,
java.util.concurrent.Callable<T> read, long remaining) throws Exception {
var writer = (java.util.concurrent.ThreadPoolExecutor) FantasyStickLifecycleNativeTest.Fixture.field(
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"), "worker");
try { return writer.submit(read).get(remaining, TimeUnit.NANOSECONDS); }
catch (java.util.concurrent.ExecutionException error) { throw new AssertionError(error.getCause()); }
return NativeTestAwait.readAudit(f, read, remaining);
}
private static void awaitExport(FantasyStickLifecycleNativeTest.Fixture f,
java.util.concurrent.Callable<Boolean> observed) throws Exception {
@@ -39,7 +39,8 @@ final class AdventureParticipationModeNativeTest {
"adventureQuests");
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "place", "camp",
"--mode", "individual-only", "10", "Solo Camp"});
await(f, () -> !adventures.snapshot().sites().isEmpty());
await(f, () -> !adventures.snapshot().sites().isEmpty()
&& admin.messages.stream().anyMatch(message -> message.contains("mode=individual-only")));
var site = adventures.snapshot().sites().getFirst();
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY, site.participationMode());
assertTrue(admin.messages.getLast().contains("mode=individual-only"));
@@ -54,7 +55,8 @@ final class AdventureParticipationModeNativeTest {
assertTrue(admin.messages.getLast().contains("individual-only"));
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "mode",
site.id().toString(), "party-required"});
drain(f);
await(f, () -> adventures.findSite(site.id()).orElseThrow().participationMode()
== AdventureQuestState.ParticipationMode.PARTY_REQUIRED);
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
adventures.findSite(site.id()).orElseThrow().participationMode());
int count = adventures.snapshot().sites().size();
@@ -74,11 +76,14 @@ final class AdventureParticipationModeNativeTest {
new String[]{"dungeon", "mode", ""}));
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "mode",
dungeon.id().toString(), "party-available"});
drain(f);
await(f, () -> dungeons.find(dungeon.id()).orElseThrow().participationMode()
== AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
dungeons.find(dungeon.id()).orElseThrow().participationMode());
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "mode",
dungeon.id().toString(), "party-required"}); drain(f);
dungeon.id().toString(), "party-required"});
await(f, () -> dungeons.find(dungeon.id()).orElseThrow().participationMode()
== AdventureQuestState.ParticipationMode.PARTY_REQUIRED);
var room = dungeons.addRadiusRoom(dungeon.id(), "Boss", 0, 64, 0, 2, 3)
.get(5, TimeUnit.SECONDS);
dungeons.setBoss(dungeon.id(), room.id(), org.bukkit.entity.EntityType.HUSK, "Keeper",
@@ -86,9 +91,11 @@ final class AdventureParticipationModeNativeTest {
dungeons.setCompletionReward(dungeon.id(), java.math.BigInteger.ZERO, java.math.BigInteger.ZERO)
.get(5, TimeUnit.SECONDS);
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "enable",
dungeon.id().toString()}); drain(f);
dungeon.id().toString()});
await(f, () -> dungeons.find(dungeon.id()).orElseThrow().enabled());
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "open",
dungeon.id().toString()}); drain(f);
dungeon.id().toString()});
await(f, () -> adventures.findSite(dungeon.id()).isPresent() && adventures.offerOpen(dungeon.id()));
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
adventures.findSite(dungeon.id()).orElseThrow().participationMode());
}
@@ -120,8 +127,7 @@ final class AdventureParticipationModeNativeTest {
var controller = (AdventureQuestController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
"adventureQuestController");
controller.open(member.player); drain(f);
var memberMenu = member.inventories.getLast();
var memberMenu = open(f, controller, member);
var personalItem = java.util.stream.IntStream.range(0, memberMenu.getSize())
.mapToObj(memberMenu::getItem).filter(java.util.Objects::nonNull)
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
@@ -134,16 +140,19 @@ final class AdventureParticipationModeNativeTest {
when(personalClick.getWhoClicked()).thenReturn(member.player);
when(personalClick.getInventory()).thenReturn(memberMenu);
when(personalClick.getCurrentItem()).thenReturn(personalItem);
controller.click(personalClick); drain(f);
int personalFeedback = member.messages.size();
controller.click(personalClick);
accepted(f, member, personalFeedback);
var personalQuest = adventures.activeFor(member.core.record.id(), party.id()).orElseThrow();
assertEquals(AdventureQuestState.UnitType.SOLO, personalQuest.unitType());
assertEquals(Set.of(member.core.record.id()), personalQuest.snapshot());
assertTrue(adventures.abandon(personalQuest.id(), personalQuest.unit()).get(5, TimeUnit.SECONDS));
controller.open(member.player); drain(f);
var memberIndividualMenu = member.inventories.getLast();
var memberIndividualMenu = open(f, controller, member);
var individualItem = offer(memberIndividualMenu, "Solo Camp");
click(controller, member, memberIndividualMenu, individualItem, false); drain(f);
int memberFeedback = member.messages.size();
click(controller, member, memberIndividualMenu, individualItem, false);
accepted(f, member, memberFeedback);
var memberIndividual = adventures.activeFor(member.core.record.id(), party.id()).orElseThrow();
assertEquals(individual.id(), memberIndividual.site());
assertEquals(AdventureQuestState.UnitType.SOLO, memberIndividual.unitType());
@@ -158,9 +167,11 @@ final class AdventureParticipationModeNativeTest {
var partyless = new FantasyStickLifecycleNativeTest.Actor(data);
partyless.core.location.setWorld(leader.world); f.players.put(partyless.core.id, partyless.player);
f.activate(partyless); controller.open(partyless.player); drain(f);
var partylessMenu = partyless.inventories.getLast();
click(controller, partyless, partylessMenu, offer(partylessMenu, "Solo Camp"), false); drain(f);
f.activate(partyless);
var partylessMenu = open(f, controller, partyless);
int partylessFeedback = partyless.messages.size();
click(controller, partyless, partylessMenu, offer(partylessMenu, "Solo Camp"), false);
accepted(f, partyless, partylessFeedback);
var partylessQuest = adventures.activeFor(partyless.core.record.id(), null).orElseThrow();
assertEquals(AdventureQuestState.UnitType.SOLO, partylessQuest.unitType());
assertTrue(adventures.abandon(partylessQuest.id(), partylessQuest.unit()).get(5, TimeUnit.SECONDS));
@@ -170,18 +181,18 @@ final class AdventureParticipationModeNativeTest {
click(controller, partyless, partylessMenu, partylessRequired, true);
assertTrue(partyless.messages.getLast().contains("Only the active party leader"));
controller.open(leader.player); drain(f);
var leaderIndividualMenu = leader.inventories.getLast();
var leaderIndividualMenu = open(f, controller, leader);
var leaderIndividualItem = offer(leaderIndividualMenu, "Solo Camp");
click(controller, leader, leaderIndividualMenu, leaderIndividualItem, false); drain(f);
int leaderFeedback = leader.messages.size();
click(controller, leader, leaderIndividualMenu, leaderIndividualItem, false);
accepted(f, leader, leaderFeedback);
var leaderIndividual = adventures.activeFor(leader.core.record.id(), party.id()).orElseThrow();
assertEquals(AdventureQuestState.UnitType.SOLO, leaderIndividual.unitType());
assertTrue(adventures.abandon(leaderIndividual.id(), leaderIndividual.unit()).get(5, TimeUnit.SECONDS));
click(controller, leader, leaderIndividualMenu, leaderIndividualItem, true);
assertTrue(leader.messages.getLast().contains("individual-only"));
controller.open(leader.player); drain(f);
var leaderMenu = leader.inventories.getLast();
var leaderMenu = open(f, controller, leader);
var requiredItem = java.util.stream.IntStream.range(0, leaderMenu.getSize())
.mapToObj(leaderMenu::getItem).filter(java.util.Objects::nonNull)
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
@@ -203,9 +214,10 @@ final class AdventureParticipationModeNativeTest {
party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id,
parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
int partyFeedback = leader.messages.size();
controller.click(partyClick);
UUID acceptedParty = party.id();
await(f, () -> adventures.activeFor(leader.core.record.id(), acceptedParty).isPresent());
accepted(f, leader, partyFeedback);
var partyQuest = adventures.activeFor(leader.core.record.id(), acceptedParty).orElseThrow();
assertEquals(AdventureQuestState.UnitType.PARTY, partyQuest.unitType());
assertEquals(Set.of(leader.core.record.id(), member.core.record.id()), partyQuest.snapshot());
@@ -228,18 +240,19 @@ final class AdventureParticipationModeNativeTest {
when(event.getCurrentItem()).thenReturn(item); when(event.isRightClick()).thenReturn(right);
controller.click(event);
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
for (int attempt = 0; attempt < 20; attempt++) {
var task = f.tasks.poll(50, TimeUnit.MILLISECONDS);
if (task == null) break;
task.run();
}
private static org.bukkit.inventory.Inventory open(FantasyStickLifecycleNativeTest.Fixture f,
AdventureQuestController controller, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
int before = actor.inventories.size();
controller.open(actor.player);
await(f, () -> actor.inventories.size() > before);
return actor.inventories.getLast();
}
private static void accepted(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor, int before) throws Exception {
await(f, () -> actor.messages.stream().skip(before).anyMatch(message -> message.startsWith("Accepted ")));
}
private static void await(FantasyStickLifecycleNativeTest.Fixture f,
java.util.function.BooleanSupplier condition) throws Exception {
for (int attempt = 0; attempt < 50 && !condition.getAsBoolean(); attempt++) {
drain(f); Thread.sleep(5);
}
assertTrue(condition.getAsBoolean());
NativeTestAwait.until(f, condition, "requested participation-mode result");
}
}
@@ -50,10 +50,10 @@ class DungeonNativeTest {
assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"dungeon",""}).contains("teleport"));
assertEquals(List.of(disabled.id().toString(),enabled.id().toString()).stream().sorted().toList(),
f.command.tabComplete(admin.player,"fantasystick",new String[]{"dungeon","teleport",""}));
f.command.execute(admin.player,"fantasystick",new String[]{"dungeon","teleport",disabled.id().toString()});drain(f);drain(f);
command(f,admin,"dungeon","teleport",disabled.id().toString());
var first=teleports.getFirst();assertSame(destination.world,first.getWorld());assertEquals(34.75,first.getX());assertEquals(66.5,first.getY());assertEquals(18.25,first.getZ());assertEquals(91.5f,first.getYaw());assertEquals(-7.25f,first.getPitch());
assertEquals("Teleported to Disabled Vault (dungeon entrance) at Dungeon Realm (34, 66, 18).",admin.messages.getLast());
f.command.execute(admin.player,"fantasystick",new String[]{"dungeon","teleport",enabled.id().toString()});drain(f);drain(f);
command(f,admin,"dungeon","teleport",enabled.id().toString());
var second=teleports.getLast();assertEquals(-20.5,second.getX());assertEquals(55.25,second.getY());assertEquals(-33.75,second.getZ());assertEquals(-135f,second.getYaw());assertEquals(22f,second.getPitch());
assertTrue(admin.messages.getLast().contains("Enabled Depths (dungeon entrance) at Dungeon Realm (-21, 55, -34)"));
assertEquals(before,directory.snapshot());
@@ -81,12 +81,12 @@ class DungeonNativeTest {
actor.particleCalls.clear();pulse(f);assertFalse(actor.particleCalls.isEmpty());assertTrue(actor.particleCalls.size()<=1024);assertTrue(actor.particleCalls.stream().allMatch(call->call[0]==Particle.DUST));var particlePreferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,false).toCompletableFuture().get(5,TimeUnit.SECONDS)));actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty(),"All-particle opt-out suppresses dungeon-editor boundaries");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,true).toCompletableFuture().get(5,TimeUnit.SECONDS)));var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);doReturn(true).when(outsider.player).hasPermission("fantasystick.admin");command(f,outsider,"dungeon","edit","start",dungeon.id().toString());assertTrue(outsider.messages.getLast().contains("editing lease"));f.command.execute(outsider.player,"fantasystick",new String[]{"dungeon","reward",dungeon.id().toString(),"90","90"});assertTrue(outsider.messages.getLast().contains("another administrator"));assertEquals(BigInteger.valueOf(30),dungeons.find(dungeon.id()).orElseThrow().completionReward().xp());assertTrue(outsider.particleCalls.isEmpty());
command(f,actor,"dungeon","edit","done");actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty());command(f,actor,"dungeon","enable",dungeon.id().toString());command(f,actor,"dungeon","open",dungeon.id().toString());
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");assertTrue(adventures.snapshot().catalog().sites().contains(dungeon.id()));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow()));
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var offerLore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(offerLore.contains("Completion trophy pool: 30 XP, 5 coins"));assertTrue(offerLore.contains("additional variable XP, coins and native loot"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");var menu=open(f,questController,actor);assertEquals(Material.PAPER,menu.getItem(0).getType());var offerLore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(offerLore.contains("Completion trophy pool: 30 XP, 5 coins"));assertTrue(offerLore.contains("additional variable XP, coins and native loot"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));int acceptedFeedback=actor.messages.size();questController.click(click);await(f,()->actor.messages.stream().skip(acceptedFeedback).anyMatch(message->message.startsWith("Accepted ")),actor.messages);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var before=storage.find(actor.core.record.id()).get().orElseThrow();var partyABefore=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBBefore=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var copiedSlot=mock(Husk.class);when(copiedSlot.getUniqueId()).thenReturn(UUID.randomUUID());var copiedProof=ordinary.getPersistentDataContainer();when(copiedSlot.getPersistentDataContainer()).thenReturn(copiedProof);assertTrue(dungeonController.encounterOrigin(copiedSlot).isEmpty(),"a copied valid dungeon slot tag is not an authenticated registered spawn");var nativeDrop=new ItemStack(Material.ROTTEN_FLESH);var forgedDeath=new EntityDeathEvent(copiedSlot,mock(org.bukkit.damage.DamageSource.class),new ArrayList<>(List.of(nativeDrop)),7);f.dispatcher.accept(forgedDeath);assertEquals(7,forgedDeath.getDroppedExp());assertEquals(List.of(nativeDrop),forgedDeath.getDrops());assertTrue(dungeonController.encounterOrigin(ordinary).isPresent(),"copied callback did not consume the real dungeon slot");var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active");
var actorAfterKills=storage.find(actor.core.record.id()).get().orElseThrow();var partyAAfterKills=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfterKills=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(boss).orElseThrow().kind(),"boss cleanup retains immutable managed origin authority");
pulse(f);await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).isPresent(),actor.messages);pulse(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var copiedSlot=mock(Husk.class);when(copiedSlot.getUniqueId()).thenReturn(UUID.randomUUID());var copiedProof=ordinary.getPersistentDataContainer();when(copiedSlot.getPersistentDataContainer()).thenReturn(copiedProof);assertTrue(dungeonController.encounterOrigin(copiedSlot).isEmpty(),"a copied valid dungeon slot tag is not an authenticated registered spawn");var nativeDrop=new ItemStack(Material.ROTTEN_FLESH);var forgedDeath=new EntityDeathEvent(copiedSlot,mock(org.bukkit.damage.DamageSource.class),new ArrayList<>(List.of(nativeDrop)),7);f.dispatcher.accept(forgedDeath);assertEquals(7,forgedDeath.getDroppedExp());assertEquals(List.of(nativeDrop),forgedDeath.getDrops());assertTrue(dungeonController.encounterOrigin(ordinary).isPresent(),"copied callback did not consume the real dungeon slot");var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);await(f,()->spawned.stream().filter(Husk.class::isInstance).count()>initialMobs.size(),actor.messages);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active");
var actorAfterKills=storage.find(actor.core.record.id()).get().orElseThrow();var partyAAfterKills=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfterKills=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);await(f,()->spawned.stream().anyMatch(Vindicator.class::isInstance),actor.messages);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);await(f,()->spawned.stream().filter(Vindicator.class::isInstance).count()>1,actor.messages);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(boss).orElseThrow().kind(),"boss cleanup retains immutable managed origin authority");
await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty()&&storage.find(actor.core.record.id()).join().orElseThrow().experience().compareTo(actorAfterKills.experience())>0,actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertEquals(boss.getUniqueId().toString(),run.completionAudit().get("boss_id"),"global completion must retain immutable native boss proof for recovery");assertFalse(run.clearedRooms().contains(skipped.id()),"boss death completes without clearing every room");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertTrue(after.experience().compareTo(before.experience())>0,"ordinary dungeon kills award standard combat XP");assertTrue(after.coins().compareTo(before.coins())>0,"ordinary dungeon kills award standard character coins");assertTrue(after.experience().compareTo(actorAfterKills.experience())>0,"genuinely killed dungeon boss grants native-classified per-kill XP");assertTrue(after.coins().compareTo(actorAfterKills.coins())>0,"genuinely killed dungeon boss grants native-classified per-kill coins");assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertTrue(partyAAfter.experience().compareTo(partyABefore.experience())>0,"party contributor receives normal kill XP");assertTrue(partyBAfter.experience().compareTo(partyBBefore.experience())>0);assertTrue(partyAAfter.experience().compareTo(partyAAfterKills.experience())>0);assertTrue(partyBAfter.experience().compareTo(partyBAfterKills.experience())>0);var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.DUNGEON,soloTrophy.type());assertEquals("Trophy of Sunken Keep",soloTrophy.name());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),soloTrophy.total());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),partyTrophy.total());assertEquals(Set.of(partyA.core.record.id(),partyB.core.record.id()),partyTrophy.rewards().keySet());assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()));var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertEquals(1,trophies.trophies(actor.core.record.id()).size(),"reroll preserves earned dungeon trophy");var claimBase=storage.find(actor.core.record.id()).get().orElseThrow();var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");var npcChunk=mock(Chunk.class);when(npcChunk.getWorld()).thenReturn(actor.world);when(npcChunk.getX()).thenReturn(actor.core.location.getBlockX()>>4);when(npcChunk.getZ()).thenReturn(actor.core.location.getBlockZ()>>4);when(npcChunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(npcChunk);when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{npcChunk});command(f,actor,"quest-giver","place","Dungeon Steward");var giverLocation=adventures.snapshot().givers().getFirst();actor.core.location.setX(giverLocation.x());actor.core.location.setZ(giverLocation.z());questController.reconcile(npcChunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow(Line truncated
await(f,()->exportedDungeonJourneyReady(data),actor.messages);
NativeTestAwait.auditUntil(f, () -> exportedDungeonJourneyReady(data), Boolean.TRUE::equals, "complete dungeon journey exports");
}
var exported=exportedDungeonEvents(data);
var changes=exported.stream().filter(event->event.get("type").getAsString()
@@ -158,7 +158,7 @@ class DungeonNativeTest {
.endsWith("adventure.reward_claimed")&&value.getAsJsonObject("data")
.getAsJsonArray("sources").asList().stream().anyMatch(source->source.getAsJsonObject()
.get("source_kind").getAsString().equals("dungeon_trophy")));
}catch(Exception transientExport){return false;}
}catch(Exception failure){throw new AssertionError(failure);}
}
private static List<com.google.gson.JsonObject> exportedDungeonEvents(Path root)throws Exception{
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
@@ -288,7 +288,7 @@ class DungeonNativeTest {
f.players.remove(leader.core.id);
assertNull(f.plugin.getServer().getPlayer(leader.core.id),
"the trophy carrier is offline when the boss dies");
pulse(f);drain(f);pulse(f);drain(f);
pulse(f);await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).isPresent(),actor.messages);pulse(f);
var boss=spawned.getLast();defeatedBoss[0]=boss.getUniqueId();
var source=mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(actor.player);
@@ -309,7 +309,7 @@ class DungeonNativeTest {
.map(DungeonState.Run::defeated).orElse(false),actor.messages);
assertEquals(defeatedBoss[0].toString(),dungeons.runView(dungeon.id(),catalog.revision())
.orElseThrow().completionAudit().get("boss_id"));
assertEquals(0,exportedDungeonEvents(root).stream().filter(event->event.get("type")
assertEquals(0,NativeTestAwait.readAudit(f, () -> exportedDungeonEvents(root)).stream().filter(event->event.get("type")
.getAsString().endsWith("dungeon.completed")).count());
}
assertTrue(new games.dmg.fantasystick.persistence.YamlDungeonRepository(root).load().runs()
@@ -317,9 +317,9 @@ class DungeonNativeTest {
.equals(run.completionAudit().get("boss_id"))));
try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(root)){
restarted.plugin.setEnabled(true);
await(restarted,()->{try{return exportedDungeonEvents(root).stream().anyMatch(event->
event.get("type").getAsString().endsWith("dungeon.completed"));}
catch(Exception transientExport){return false;}},List.of());
NativeTestAwait.auditUntil(restarted, () -> exportedDungeonEvents(root),
records -> records.stream().anyMatch(event -> event.get("type").getAsString()
.endsWith("dungeon.completed")), "replayed dungeon completion");
var runs=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin,"dungeons");
await(restarted,()->runs.snapshot().runs().stream().anyMatch(run->run.defeated()
&&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id"))
@@ -394,8 +394,8 @@ class DungeonNativeTest {
doAnswer(call->{var mob=mock(Vindicator.class);var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var damage=mock(org.bukkit.attribute.AttributeInstance.class);var equipment=mock(org.bukkit.inventory.EntityEquipment.class);var maximumBase=new double[]{20};var attackBase=new double[]{1};var health=new double[]{20};var effects=new LinkedHashSet<org.bukkit.potion.PotionEffect>();effects.add(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS,200,0));maximumBases.put(mob,maximumBase);attackBases.put(mob,attackBase);bossEffects.put(mob,effects);bossEquipment.put(mob,equipment);when(mob.getPersistentDataContainer()).thenReturn(pdc);when(mob.getUniqueId()).thenReturn(UUID.randomUUID());when(mob.getType()).thenReturn(EntityType.VINDICATOR);when(mob.getWorld()).thenReturn(admin.world);when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
when(admin.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeons.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");await(f,()->adventures.snapshot().catalog()!=null&&adventures.snapshot().catalog().sites().contains(dungeon.id()),admin.messages);
var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList<Double>();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);drain(f);pulse(f);drain(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat");
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();killedBossId[0]=freshBoss.getUniqueId();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);challenger.core.location.setX(100);
var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList<Double>();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).isPresent(),admin.messages);pulse(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat");
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);await(f,()->spawned.getLast()!=boss,admin.messages);var freshBoss=spawned.getLast();killedBossId[0]=freshBoss.getUniqueId();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);challenger.core.location.setX(100);
int disableFeedback = admin.messages.size();
command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm");
ReskillEventsNativeTest.await(f,()->admin.messages.stream().skip(disableFeedback)
@@ -404,7 +404,7 @@ class DungeonNativeTest {
assertFalse(adventures.offerOpen(dungeon.id()));
assertTrue(adventures.findSite(dungeon.id()).isEmpty());
admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent()));
await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);
NativeTestAwait.auditUntil(f, () -> exportedDungeonEvents(root), published -> published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy")), "completion and trophy exports");
}
var exported=exportedDungeonEvents(root);
var bossAwards=exported.stream().filter(event->event.get("type").getAsString()
@@ -427,11 +427,21 @@ class DungeonNativeTest {
}
@Test void questGiverPaginatesManualDungeonOffersBeyondAutomaticCount() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var sites=new ArrayList<AdventureQuestState.Site>();for(int i=0;i<46;i++)sites.add(new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.DUNGEON,"Dungeon "+i,world,i,64,0));var catalog=new AdventureQuestState.Catalog(java.time.LocalDate.now(java.time.ZoneOffset.UTC),UUID.randomUUID(),java.time.Instant.now(),sites.stream().map(AdventureQuestState.Site::id).toList());var field=directory.getClass().getDeclaredField("snapshot");field.setAccessible(true);field.set(directory,new AdventureQuestState(sites,List.of(),catalog,List.of(),Set.of(),Set.of(),Map.of()));var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var first=actor.inventories.getLast();assertEquals(54,first.getSize());for(int slot=0;slot<45;slot++)assertEquals(Material.PAPER,first.getItem(slot).getType());assertEquals(Material.ARROW,first.getItem(53).getType());var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(first);when(click.getCurrentItem()).thenReturn(first.getItem(53));controller.click(click);drain(f);var second=actor.inventories.getLast();assertNotSame(first,second);assertEquals(Material.PAPER,second.getItem(0).getType());assertTrue(second.getItem(0).getItemMeta().displayName().toString().contains("Dungeon 45"));}
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var sites=new ArrayList<AdventureQuestState.Site>();for(int i=0;i<46;i++)sites.add(new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.DUNGEON,"Dungeon "+i,world,i,64,0));var catalog=new AdventureQuestState.Catalog(java.time.LocalDate.now(java.time.ZoneOffset.UTC),UUID.randomUUID(),java.time.Instant.now(),sites.stream().map(AdventureQuestState.Site::id).toList());var field=directory.getClass().getDeclaredField("snapshot");field.setAccessible(true);field.set(directory,new AdventureQuestState(sites,List.of(),catalog,List.of(),Set.of(),Set.of(),Map.of()));var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");var first=open(f,controller,actor);assertEquals(54,first.getSize());for(int slot=0;slot<45;slot++)assertEquals(Material.PAPER,first.getItem(slot).getType());assertEquals(Material.ARROW,first.getItem(53).getType());var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(first);when(click.getCurrentItem()).thenReturn(first.getItem(53));int beforePage=actor.inventories.size();controller.click(click);await(f,()->actor.inventories.size()>beforePage,actor.messages);var second=actor.inventories.getLast();assertNotSame(first,second);assertEquals(Material.PAPER,second.getItem(0).getType());assertTrue(second.getItem(0).getItemMeta().displayName().toString().contains("Dungeon 45"));}
}
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String... args)throws Exception{f.command.execute(actor.player,"fantasystick",args);drain(f);}
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String... args)throws Exception{
// These serialized authoring routes respond only at their terminal native outcome.
int before=actor.messages.size();
f.command.execute(actor.player,"fantasystick",args);
NativeTestAwait.until(f,()->actor.messages.size()>before,"native response to "+String.join(" ",args));
}
private static org.bukkit.inventory.Inventory open(FantasyStickLifecycleNativeTest.Fixture f,
AdventureQuestController controller,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{
int before=actor.inventories.size();controller.open(actor.player);
NativeTestAwait.until(f,()->actor.inventories.size()>before,"new Quest Giver menu");
return actor.inventories.getLast();
}
private static void pulse(FantasyStickLifecycleNativeTest.Fixture f){for(var task:List.copyOf(f.pulses))task.run();}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{Runnable task;for(int attempt=0;attempt<20;attempt++){task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task==null)break;task.run();}}
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition,Object evidence)throws Exception{for(int attempt=0;attempt<50&&!condition.getAsBoolean();attempt++){drain(f);Thread.sleep(5);}assertTrue(condition.getAsBoolean(),String.valueOf(evidence));}
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition,Object evidence)throws Exception{NativeTestAwait.until(f,condition,"dungeon result: "+evidence);}
}
@@ -98,7 +98,7 @@ class DungeonRoomEffectLifetimeNativeTest {
fixture.actor.core.location.setX(70); fixture.shared.pulse();
if (port.cancelRemoval) {
assertEquals(port.aged(beforeExit, 1), port.current(PotionEffectType.SPEED));
assertEquals(0, port.removals); assertTrue(rows("effect.ended").isEmpty());
assertEquals(0, port.removals); assertTrue(NativeTestAwait.readAudit(fixture.f, () -> rows("effect.ended")).isEmpty());
port.cancelRemoval = false; fixture.shared.pulse();
}
if (port.cancelExtend) {
@@ -120,15 +120,15 @@ class DungeonRoomEffectLifetimeNativeTest {
assertEquals(fixture.quest, fixture.shared.directory.findQuest(fixture.quest.id()).orElseThrow());
// Observe gameplay exports while the logger is open; shutdown may retain valid durable intents.
try {
// These owner intents were submitted by the already verified native calls.
// A ready main-thread callback must not consume this IO-only 100 x 50 ms budget.
for (int attempt = 0; attempt < 100
&& (rows("effect.applied").size() != 1 || rows("effect.ended").size() != 1); attempt++)
Thread.sleep(50);
assertEquals(1, rows("effect.applied").size());
assertEquals(1, rows("effect.ended").size());
var exported = NativeTestAwait.auditUntil(fixture.f,
() -> List.of(rows("effect.applied"), rows("effect.ended")),
rows -> rows.get(0).size() == 1 && rows.get(1).size() == 1,
"one applied and one ended room effect");
assertEquals(1, exported.get(0).size());
assertEquals(1, exported.get(1).size());
} catch (AssertionError failure) {
var applied = rows("effect.applied"); var ended = rows("effect.ended");
var applied = NativeTestAwait.readAudit(fixture.f, () -> rows("effect.applied"));
var ended = NativeTestAwait.readAudit(fixture.f, () -> rows("effect.ended"));
throw new AssertionError("Room export fence: applied=" + applied.size()
+ ", appliedLogical=" + applied.stream().map(row -> row.get("id").getAsString()).distinct().count()
+ ", ended=" + ended.size()
@@ -22,6 +22,17 @@ class FeatureAuditSaturationNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
private static java.util.List<com.google.gson.JsonObject> rows(FantasyStickLifecycleNativeTest.Fixture f,
Path root, String type) throws Exception {
return NativeTestAwait.readAudit(f, () -> QuestTrackingEventsNativeTest.rows(root, type));
}
private static void awaitRows(FantasyStickLifecycleNativeTest.Fixture f, Path root,
String type, int count) throws Exception {
NativeTestAwait.auditUntil(f, () -> QuestTrackingEventsNativeTest.rows(root, type),
records -> records.size() >= count, type + " export");
}
@Test void actualPluginStartupAutomaticallyExportsOriginalIntentAfterOtherOwnerCapacityReturns() throws Exception {
var pluginData = data.resolve("startup-plugin");
var saved = new CopyOnWriteArrayList<games.dmg.fantasystick.events.EventInput>();
@@ -47,27 +58,26 @@ class FeatureAuditSaturationNativeTest {
assertTrue(full.await(30, TimeUnit.SECONDS));
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(pluginData)) {
restarted.plugin.setEnabled(true); // Actual production startup invokes replay, not this test.
ReskillEventsNativeTest.await(restarted, () -> {
try { return ReskillEventsNativeTest.records(pluginData).stream()
.filter(row -> row.get("type").getAsString().endsWith("game.started")).count() == 2; }
catch (Exception failure) { throw new AssertionError(failure); }
});
assertTrue(rows(pluginData, "action.observed").stream().noneMatch(row -> row.get("id").getAsString().equals(proof.id().toString())));
NativeTestAwait.auditUntil(restarted, () -> ReskillEventsNativeTest.records(pluginData),
records -> records.stream().filter(row -> row.get("type").getAsString()
.endsWith("game.started")).count() == 2, "second startup export");
assertTrue(rows(restarted, pluginData, "action.observed").stream().noneMatch(row -> row.get("id").getAsString().equals(proof.id().toString())));
assertTrue(Files.exists(pluginData.resolve("quest-tracking-event-intents").resolve(proof.id() + ".pending")));
held.complete(LocalEventLog.Delivery.REJECTED);
ReskillEventsNativeTest.await(restarted, () -> {
try { return rows(pluginData, "action.observed").stream()
.anyMatch(row -> row.get("id").getAsString().equals(proof.id().toString())); }
catch (Exception failure) { throw new AssertionError(failure); }
});
var exported = rows(pluginData, "action.observed").stream()
var replayed = NativeTestAwait.auditUntil(restarted,
() -> QuestTrackingEventsNativeTest.rows(pluginData, "action.observed"),
records -> records.stream().anyMatch(row -> row.get("id").getAsString().equals(proof.id().toString())),
"original replay export");
var exported = replayed.stream()
.filter(row -> row.get("id").getAsString().equals(proof.id().toString())).toList();
assertEquals(1, exported.size()); var row = exported.getFirst();
assertEquals(proof.time().toString(), row.get("time").getAsString());
assertEquals(proof.context().playerId().toString(), row.get("playerid").getAsString());
for (var entry : proof.data().entrySet()) assertEquals(new com.google.gson.Gson().toJsonTree(entry.getValue()),
row.getAsJsonObject("data").get(entry.getKey()), entry.getKey());
assertFalse(Files.exists(pluginData.resolve("quest-tracking-event-intents").resolve(proof.id() + ".pending")));
var pending = pluginData.resolve("quest-tracking-event-intents").resolve(proof.id() + ".pending");
NativeTestAwait.until(restarted, () -> !Files.exists(pending), "export acknowledgment retired original intent");
assertFalse(Files.exists(pending));
}
} finally { held.complete(LocalEventLog.Delivery.REJECTED); blocker.close(); blocker.drained().toCompletableFuture().get(10, TimeUnit.SECONDS); }
}
@@ -118,7 +128,7 @@ class FeatureAuditSaturationNativeTest {
ReskillEventsNativeTest.await(f, () -> actor.messages.contains("Quest tracking is now on."));
assertTrue(fixture.directory.trackingEnabled(quest.id(), actor.core.record.id()));
assertEquals(Material.COMPASS, actor.core.inventory.getItem(0).getType());
assertTrue(rows(data.resolve("logger-plugin"), "preference.read").isEmpty());
assertTrue(rows(f, data.resolve("logger-plugin"), "preference.read").isEmpty());
} finally {
releaseDiagnostic.countDown(); f.plugin.getLogger().removeHandler(handler);
held.complete(LocalEventLog.Delivery.REJECTED); blocker.close();
@@ -165,9 +175,9 @@ class FeatureAuditSaturationNativeTest {
assertArrayEquals(source, Files.readAllBytes(data.resolve("plugin/adventure-quests.yml")));
var after = actor.core.snapshot();
for (int i = 1; i < inventory.length; i++) assertEquals(inventory[i], after[i]);
assertEquals(1, rows(data.resolve("plugin"), "preference.changed").size());
assertTrue(rows(data.resolve("plugin"), "preference.read").isEmpty());
assertTrue(rows(data.resolve("plugin"), "action.observed").isEmpty());
assertEquals(1, rows(f, data.resolve("plugin"), "preference.changed").size());
assertTrue(rows(f, data.resolve("plugin"), "preference.read").isEmpty());
assertTrue(rows(f, data.resolve("plugin"), "action.observed").isEmpty());
} finally {
held.complete(LocalEventLog.Delivery.REJECTED);
blocker.close();
@@ -177,7 +187,7 @@ class FeatureAuditSaturationNativeTest {
// Once capacity recovers, the actual registered query reaches parsed JSONL again.
f.playerCommand.execute(actor.player, "fstick", new String[]{"adventure", "tracking"});
awaitRows(f, data.resolve("plugin"), "preference.read", 1);
assertTrue(rows(data.resolve("plugin"), "preference.read").getFirst().getAsJsonObject("data").get("saved_value").getAsBoolean());
assertTrue(rows(f, data.resolve("plugin"), "preference.read").getFirst().getAsJsonObject("data").get("saved_value").getAsBoolean());
}
}
}
@@ -0,0 +1,59 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
import java.util.function.Predicate;
/** Test-only acknowledgments; gameplay time and production executors remain unchanged. */
final class NativeTestAwait {
private NativeTestAwait() { }
static void until(FantasyStickLifecycleNativeTest.Fixture fixture, BooleanSupplier completed,
String description) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (!completed.getAsBoolean()) pump(fixture, deadline, description);
}
/** Returns the same strict snapshot that satisfied the predicate, never a later unfenced reread. */
static <T> T auditUntil(FantasyStickLifecycleNativeTest.Fixture fixture, Callable<T> read,
Predicate<T> completed, String description) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (true) {
var snapshot = readAudit(fixture, read, remaining(deadline, description));
if (completed.test(snapshot)) return snapshot;
pump(fixture, deadline, description);
}
}
private static void pump(FantasyStickLifecycleNativeTest.Fixture fixture, long deadline,
String description) throws Exception {
var task = fixture.tasks.poll(Math.min(remaining(deadline, description),
TimeUnit.MILLISECONDS.toNanos(10)), TimeUnit.NANOSECONDS);
if (task != null) task.run();
}
private static long remaining(long deadline, String description) {
long remaining = deadline - System.nanoTime();
assertTrue(remaining > 0, "Missing acknowledgment within five seconds: " + description);
return remaining;
}
static <T> T readAudit(FantasyStickLifecycleNativeTest.Fixture fixture, Callable<T> read) throws Exception {
return readAudit(fixture, read, TimeUnit.SECONDS.toNanos(5));
}
static <T> T readAudit(FantasyStickLifecycleNativeTest.Fixture fixture, Callable<T> read,
long remainingNanos) throws Exception {
// Run the entire strict read on this fixture's serial writer, not merely a fence before
// an unsynchronized read. Later appends cannot interleave with parsing the snapshot.
var writer = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog"), "worker");
try { return writer.submit(read).get(remainingNanos, TimeUnit.NANOSECONDS); }
catch (ExecutionException failure) { throw new AssertionError(failure.getCause()); }
}
}
@@ -0,0 +1,51 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NativeTestAwaitNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void quietQueueAndUnrelatedCallbackDoNotAcknowledgeTheRequestedResult() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var result = new AtomicBoolean();
var unrelated = new AtomicBoolean();
var returned = new CountDownLatch(1);
var premature = new AtomicBoolean();
f.tasks.add(() -> unrelated.set(true));
var delayed = CompletableFuture.runAsync(() -> {
try { premature.set(returned.await(200, TimeUnit.MILLISECONDS)); }
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); }
finally { f.tasks.add(() -> result.set(true)); }
});
try {
NativeTestAwait.until(f, result::get, "requested native result");
returned.countDown(); delayed.get(5, TimeUnit.SECONDS);
assertTrue(unrelated.get());
assertTrue(result.get());
assertFalse(premature.get(), "an empty queue is not completion of delayed work");
} finally {
returned.countDown(); delayed.get(5, TimeUnit.SECONDS);
}
}
}
@Test void acknowledgedResultReturnsWithoutConsumingUnrelatedQueuedWork() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var unrelated = new AtomicBoolean();
f.tasks.add(() -> unrelated.set(true));
NativeTestAwait.until(f, () -> true, "already acknowledged");
assertFalse(unrelated.get(), "acknowledgment is not a request to drain arbitrary work");
assertEquals(1, f.tasks.size());
}
}
}
@@ -33,7 +33,7 @@ class RareShopNativeTest {
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);int x=actor.core.location.getBlockX()>>4,z=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(x);when(chunk.getZ()).thenReturn(z);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(x,z)).thenReturn(chunk);when(actor.world.isChunkLoaded(x,z)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of());
assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"rare-shop",""}));assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-"}).containsAll(List.of("rare-price-health-potion","rare-price-reskill","rare-stock-reskill-min","rare-stock-reskill-max","rare-price-magic-core","rare-stock-magic-core-min","rare-stock-magic-core-max","rare-price-phoenix-down","rare-stock-phoenix-down-min","rare-stock-phoenix-down-max","rare-phoenix-down-invulnerability-seconds")));assertEquals(List.of("20","2000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-reskill",""}));assertEquals(List.of("30","3000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-magic-core",""}));
var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
f.command.execute(actor.player,"fantasystick",new String[]{"rare-shop","place","Curios"});await(f,()->((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).all().size()==1);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var shop=directory.all().getFirst();assertEquals("Curios",shop.name());var controller=(RareMerchantController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchantController");assertTrue(controller.tagged(villager));
f.command.execute(actor.player,"fantasystick",new String[]{"rare-shop","place","Curios"});await(f,()->((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).all().size()==1);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var shop=directory.all().getFirst();assertEquals("Curios",shop.name());var controller=(RareMerchantController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchantController");NativeTestAwait.until(f,()->controller.tagged(villager),"saved merchant reached native spawn/tagging");assertTrue(controller.tagged(villager));
var repository=new YamlCharacterRepository(data);var old=repository.load(actor.core.record.id()).orElseThrow();var purchasedStats=new java.util.EnumMap<>(old.stats());purchasedStats.put(games.dmg.fantasystick.progression.Stat.DEX,BigInteger.ONE);repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),purchasedStats,old.level(),old.experience(),BigInteger.valueOf(39),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(150),true,old.rewardReceipts(),old.pvpCoinEscrow(),old.gear()),old.revision());f.activate(actor);
int opened=actor.inventories.size(),books=actor.books.size();var main=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);var offhand=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(main);f.dispatcher.accept(offhand);assertTrue(main.isCancelled());assertTrue(offhand.isCancelled());await(f,()->actor.inventories.size()==opened+1);assertEquals(books,actor.books.size());var menu=actor.inventories.getLast();var potion=menu.getItem(11);assertEquals(Material.POTION,potion.getType());assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(potion.getItemMeta().displayName()).contains("Health Potion — 40 coins"));assertEquals("Stock: 1",net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(potion.getItemMeta().lore().getFirst()));
var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");var click=mock(InventoryClickEvent.class);when(click.getInventory()).thenReturn(menu);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getCurrentItem()).thenReturn(potion);menus.click(click);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION).equals(BigInteger.ONE));var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");await(f,()->sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(110))).orElse(false));var active=sessions.active(actor.core.id).orElseThrow(()->new AssertionError("Purchase removed active session: "+actor.messages));var record=sessions.currentRecord(active).orElseThrow(()->new AssertionError("Purchase left no current record: "+actor.messages));var injured=characters.saveHealth(record.id(),record.revision(),BigDecimal.valueOf(5),BigDecimal.valueOf(100)).join().orElseThrow(()->new AssertionError("Injury save was refused"));assertTrue(sessions.acknowledgeProgress(active,record.revision(),injured));actor.player.setHealth(5);
@@ -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();
@@ -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());
}
}