Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4ae188501 | ||
|
|
5e5e21374f | ||
|
|
7353436a77 | ||
|
|
a19909f152 | ||
|
|
7b6ea939a5 | ||
|
|
f2f0dd742f | ||
|
|
c0b5393646 | ||
|
|
cd4d5a1360 | ||
|
|
6563f61f19 | ||
|
|
def83dcb32 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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,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.
|
||||
@@ -26,6 +39,8 @@ Fantasy Stick independently writes compact CloudEvents 1.0 JSON objects as UTF-8
|
||||
|
||||
A stable plugin-owned server UUID and per-lifetime boot/sequence identity distinguish records. Events rotate at UTC midnight, on each writer lifetime and before 128 MiB; an oversized record stays whole in its own segment. Accepted work is first retained in internal pending files and exported at least once with unchanged IDs, so consumers must deduplicate by `(source, id)`. Startup recovers pending work, quarantines malformed/interrupted tails outside `*.jsonl`, and reports incomplete prior lifetimes without inventing a shutdown. Queue overload and pre-durable native-observation crashes remain truthful loss windows; logging never rolls back or replays gameplay.
|
||||
|
||||
Feature-outbox aggregate overload diagnostics are bounded, rate-limited and off the gameplay thread. Each outbox gives diagnostics only the existing five-second closing-attempt budget: afterward it stops new aggregate callbacks, interrupts its own cooperative handler and retires maintenance bookkeeping once its intent worker finishes. Late rejected observations remain counted but cannot restart aggregate callbacks or resurrect that closed diagnostic owner; they are never durable/exported. Durable pending intents remain available for replay. A completed closing attempt is **not** proof that aggregate diagnostics were logged or that a supplied handler terminated. An interruption-ignoring trusted in-process handler can still retain its executing thread/callback/plugin classloader (and can stall shared diagnostics while running); Java cannot safely kill it. Pending or interrupted aggregate reports are best-effort, not durable event export acknowledgments. Other blocking storage/sink/error callbacks are not made safely cancellable by this safeguard.
|
||||
|
||||
These files have no automatic deletion, compression or retention limit, so disk use grows until operators manage it under a separately approved policy. Current generic records include stable account/character/session IDs and precise locations; later feature-owned event slices may include complete public and recognized private-message text. Restrict event files and backups to operators accordingly. They are not encrypted or cryptographically tamper-evident, and feedback/particle preferences do not disable auditing.
|
||||
|
||||
## Character storage and keepsakes
|
||||
@@ -183,6 +198,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 +230,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.
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
synchronized (MAINTENANCE_LIFETIME) {
|
||||
OWNERS.remove(owner);
|
||||
if (OWNERS.isEmpty()) {
|
||||
REPLAYS.shutdown(); DIAGNOSTICS.shutdown();
|
||||
REPLAYS.shutdown(); DIAGNOSTICS.shutdownNow();
|
||||
REPLAYS = null; DIAGNOSTICS = null;
|
||||
}
|
||||
}
|
||||
@@ -84,10 +84,14 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
private final Path directory;
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean workerFinished = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final ExecutorService worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(MAX_HANDOFFS), task -> new Thread(task, "fantasy-feature-event-intents"),
|
||||
new ThreadPoolExecutor.AbortPolicy()) {
|
||||
@Override protected void terminated() { closeReplayCursor(); }
|
||||
@Override protected void terminated() {
|
||||
try { closeReplayCursor(); }
|
||||
finally { workerFinished.set(true); retire(); }
|
||||
}
|
||||
};
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
private final Set<UUID> inFlight = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
@@ -100,6 +104,10 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
private final java.util.concurrent.atomic.AtomicLong rejected = new java.util.concurrent.atomic.AtomicLong();
|
||||
private final java.util.concurrent.atomic.AtomicLong deferred = new java.util.concurrent.atomic.AtomicLong();
|
||||
private final Object diagnosticFence = new Object();
|
||||
// Terminal after the bounded closing attempt; late rejections cannot resurrect this owner.
|
||||
private volatile boolean diagnosticsStopped;
|
||||
// Guarded by diagnosticFence: interrupt only this owner's active aggregate callback.
|
||||
private Thread diagnosticThread;
|
||||
private volatile long lastDiagnostic;
|
||||
private volatile boolean replayRequested;
|
||||
// The directory cursor is confined to the intent worker; it retains no materialized backlog.
|
||||
@@ -118,39 +126,66 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
}
|
||||
|
||||
private boolean register() {
|
||||
if (diagnosticsStopped) return false;
|
||||
if (!registration.compareAndSet(0, 3)) return true;
|
||||
if (!OWNER_SLOTS.tryAcquire()) { registration.set(0); return false; }
|
||||
attach(this); registration.set(1); return true;
|
||||
attach(this); registration.set(1);
|
||||
// Closing may finish while a producer is attaching; retire that raced registration too.
|
||||
if (diagnosticsStopped) retire();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void retire() {
|
||||
if (!worker.isTerminated() || rejected.get() != 0 || deferred.get() != 0 || diagnosing.get()) return;
|
||||
if (!workerFinished.get() || (!diagnosticsStopped
|
||||
&& (rejected.get() != 0 || deferred.get() != 0 || diagnosing.get()))) return;
|
||||
if (!registration.compareAndSet(1, 2)) return;
|
||||
detach(this); OWNER_SLOTS.release(); registration.set(0);
|
||||
// A producer seeing state 2 cannot reattach until removal finishes; its counts remain owned here.
|
||||
if (rejected.get() != 0 || deferred.get() != 0) register();
|
||||
if (!diagnosticsStopped && (rejected.get() != 0 || deferred.get() != 0)) register();
|
||||
}
|
||||
|
||||
private void rejectObservation() { rejected.incrementAndGet(); register(); }
|
||||
|
||||
private void diagnose() {
|
||||
if (!diagnosing.compareAndSet(false, true)) return;
|
||||
synchronized (diagnosticFence) {
|
||||
if (diagnosticsStopped || !diagnosing.compareAndSet(false, true)) return;
|
||||
diagnosticThread = Thread.currentThread();
|
||||
}
|
||||
try {
|
||||
long now = System.nanoTime();
|
||||
if (!closingDiagnosis.getAndSet(false) && lastDiagnostic != 0
|
||||
&& now - lastDiagnostic < TimeUnit.SECONDS.toNanos(1)) return;
|
||||
long lost = rejected.getAndSet(0), delayed = deferred.getAndSet(0);
|
||||
if (lost != 0 || delayed != 0) {
|
||||
lastDiagnostic = now;
|
||||
report(new HandoffRejection(lost, delayed));
|
||||
long lost, delayed;
|
||||
synchronized (diagnosticFence) {
|
||||
// Admission/dequeue shares the stop fence: a late count cannot be withdrawn
|
||||
// by a diagnostic pass selected just before terminal shutdown.
|
||||
if (diagnosticsStopped) return;
|
||||
long now = System.nanoTime();
|
||||
if (!closingDiagnosis.getAndSet(false) && lastDiagnostic != 0
|
||||
&& now - lastDiagnostic < TimeUnit.SECONDS.toNanos(1)) return;
|
||||
lost = rejected.getAndSet(0); delayed = deferred.getAndSet(0);
|
||||
if (lost != 0 || delayed != 0) lastDiagnostic = now;
|
||||
}
|
||||
if (lost != 0 || delayed != 0) report(new HandoffRejection(lost, delayed));
|
||||
} finally {
|
||||
diagnosing.set(false);
|
||||
synchronized (diagnosticFence) { diagnosticFence.notifyAll(); }
|
||||
synchronized (diagnosticFence) {
|
||||
diagnosticThread = null;
|
||||
diagnosing.set(false);
|
||||
// Do not leak our cooperative cancellation into another owner's callback.
|
||||
if (diagnosticsStopped) Thread.interrupted();
|
||||
diagnosticFence.notifyAll();
|
||||
}
|
||||
retire();
|
||||
}
|
||||
}
|
||||
|
||||
private void stopDiagnostics() {
|
||||
synchronized (diagnosticFence) {
|
||||
diagnosticsStopped = true;
|
||||
if (diagnosticThread != null) diagnosticThread.interrupt();
|
||||
}
|
||||
// A non-cooperative handler can still retain its own executing stack/classloader.
|
||||
// Detachment is bookkeeping retirement, never proof that diagnostics were delivered.
|
||||
retire();
|
||||
}
|
||||
|
||||
void accepted(EventInput event) {
|
||||
if (!admit()) return;
|
||||
try { worker.execute(() -> {
|
||||
@@ -429,15 +464,20 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
synchronized (diagnosticFence) {
|
||||
while (rejected.get() != 0 || deferred.get() != 0 || diagnosing.get()) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0) break; // Bounded shutdown attempt; maintenance retains the aggregate diagnosis.
|
||||
if (remaining <= 0) break; // Best-effort diagnostics stop at this lifetime's budget.
|
||||
TimeUnit.NANOSECONDS.timedWait(diagnosticFence, remaining);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); report(interrupted);
|
||||
} finally { retire(); drained.complete(null); }
|
||||
} finally { stopDiagnostics(); drained.complete(null); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Observations not submitted to an aggregate callback, including late calls after diagnostic retirement.
|
||||
* This is neither a durable-event count nor acknowledgment of earlier callback delivery. */
|
||||
long pendingRejectedObservations() { return rejected.get(); }
|
||||
|
||||
/** Bounded closing attempt, not an acknowledgment of diagnostic delivery or handler termination. */
|
||||
CompletionStage<Void> drained() { return drained; }
|
||||
}
|
||||
@@ -60,6 +60,7 @@ public final class LocalEventLog implements AutoCloseable {
|
||||
private final java.nio.channels.FileLock writerLock;
|
||||
private long sequence;
|
||||
private Path segment;
|
||||
private Path damagedSegment;
|
||||
private LocalDate segmentDate;
|
||||
private long segmentBytes;
|
||||
private int segmentNumber;
|
||||
@@ -203,24 +204,40 @@ public final class LocalEventLog implements AutoCloseable {
|
||||
}
|
||||
boolean recovered = false;
|
||||
for (var path : files) {
|
||||
if (Files.isSymbolicLink(path)) throw new IOException("Symbolic link in Fantasy Stick event segments");
|
||||
var bytes = Files.readAllBytes(path);
|
||||
int prefix=0,cursor=0;while(cursor<bytes.length){int end=cursor;while(end<bytes.length&&bytes[end]!='\n')end++;if(end==bytes.length)break;var line=java.util.Arrays.copyOfRange(bytes,cursor,end+1);if(!validJsonObjectLine(line))break;prefix=end+1;cursor=end+1;}if(prefix==bytes.length)continue;
|
||||
recovered = true;
|
||||
var quarantine = path.resolveSibling(path.getFileName() + ".corrupt-" + UUID.randomUUID());
|
||||
try (var channel = FileChannel.open(quarantine, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
||||
var tail = ByteBuffer.wrap(bytes, prefix, bytes.length - prefix);
|
||||
while (tail.hasRemaining()) channel.write(tail);
|
||||
channel.force(true);
|
||||
}
|
||||
try (var channel = FileChannel.open(path, StandardOpenOption.WRITE)) {
|
||||
channel.truncate(prefix);
|
||||
channel.force(true);
|
||||
}
|
||||
recovered |= recoverInterruptedTail(path);
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
/** Quarantine must be forced before truncation; failure leaves pending work and repair state intact. */
|
||||
private boolean recoverInterruptedTail(Path path) throws IOException {
|
||||
if (Files.isSymbolicLink(path)) throw new IOException("Symbolic link in Fantasy Stick event segments");
|
||||
var bytes = Files.readAllBytes(path);
|
||||
int prefix = 0;
|
||||
int cursor = 0;
|
||||
while (cursor < bytes.length) {
|
||||
int end = cursor;
|
||||
while (end < bytes.length && bytes[end] != '\n') end++;
|
||||
if (end == bytes.length) break;
|
||||
var line = java.util.Arrays.copyOfRange(bytes, cursor, end + 1);
|
||||
if (!validJsonObjectLine(line)) break;
|
||||
prefix = end + 1;
|
||||
cursor = end + 1;
|
||||
}
|
||||
if (prefix == bytes.length) return false;
|
||||
var quarantine = path.resolveSibling(path.getFileName() + ".corrupt-" + UUID.randomUUID());
|
||||
try (var channel = FileChannel.open(quarantine, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
||||
var tail = ByteBuffer.wrap(bytes, prefix, bytes.length - prefix);
|
||||
while (tail.hasRemaining()) channel.write(tail);
|
||||
channel.force(true);
|
||||
}
|
||||
try (var channel = FileChannel.open(path, StandardOpenOption.WRITE)) {
|
||||
channel.truncate(prefix);
|
||||
channel.force(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int recoverPending() throws IOException {
|
||||
List<Path> files;
|
||||
try (var stream = Files.list(pending)) {
|
||||
@@ -247,6 +264,10 @@ public final class LocalEventLog implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void append(byte[] line) throws IOException {
|
||||
if (damagedSegment != null) {
|
||||
recoverInterruptedTail(damagedSegment);
|
||||
damagedSegment = null;
|
||||
}
|
||||
var date = LocalDate.ofInstant(clock.instant(), ZoneOffset.UTC);
|
||||
if (segment == null || !date.equals(segmentDate)
|
||||
|| segmentBytes > 0 && segmentBytes + line.length > maximumSegmentBytes) {
|
||||
@@ -256,6 +277,14 @@ public final class LocalEventLog implements AutoCloseable {
|
||||
var buffer = ByteBuffer.wrap(line);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
} catch (IOException failure) {
|
||||
// Even open/force/close failures can leave uncertain bytes. Never reuse this
|
||||
// segment or acknowledge pending work until its tail has been repaired.
|
||||
damagedSegment = segment;
|
||||
segment = null;
|
||||
segmentDate = null;
|
||||
segmentBytes = 0;
|
||||
throw failure;
|
||||
}
|
||||
segmentBytes += line.length;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+35
-15
@@ -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"));
|
||||
|
||||
+1
-4
@@ -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 {
|
||||
|
||||
+44
-31
@@ -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()
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static games.dmg.fantasystick.QuestTrackingEventsNativeTest.*;
|
||||
import static games.dmg.fantasystick.QuestTrackingSafetyEventsNativeTest.acceptSolo;
|
||||
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.events.QuestTrackingEventBridge;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
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;
|
||||
|
||||
/** Actual packaged disable must cancel the handler wired by the production event bridge. */
|
||||
class FeatureAuditRetirementNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void packagedDisableInterruptsBlockedProductionLoggerWithoutReleasingItFromTheTest() throws Exception {
|
||||
var pluginData = data.resolve("plugin");
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(pluginData)) {
|
||||
acceptSolo(fixture);
|
||||
var f = fixture.f; var actor = fixture.actor; var quest = fixture.directory.active().getFirst();
|
||||
awaitRows(f, pluginData, "preference.changed", 1);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var original = (QuestTrackingEventBridge) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "questTrackingEvents");
|
||||
var originalOutbox = FantasyStickLifecycleNativeTest.Fixture.field(original, "outbox");
|
||||
var held = new CompletableFuture<LocalEventLog.Delivery>();
|
||||
var full = new CountDownLatch(1024);
|
||||
var blocker = new QuestTrackingEventBridge(input -> { full.countDown(); return held; },
|
||||
data.resolve("held"), original::actor, ignored -> {});
|
||||
var blockerWorker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(blocker, "outbox"), "worker");
|
||||
var nativeThread = Thread.currentThread();
|
||||
var onCaller = new AtomicBoolean();
|
||||
var entered = new CountDownLatch(1); var interrupted = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var handler = new java.util.logging.Handler() {
|
||||
@Override public void publish(java.util.logging.LogRecord record) {
|
||||
if (!"Quest tracking event intent failed".equals(record.getMessage())) return;
|
||||
if (Thread.currentThread() == nativeThread) { onCaller.set(true); return; }
|
||||
entered.countDown();
|
||||
try { release.await(); }
|
||||
catch (InterruptedException expected) { interrupted.countDown(); Thread.currentThread().interrupt(); }
|
||||
}
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
};
|
||||
try {
|
||||
for (int i = 0; i < 1024; i++) blocker.observed(blocker.request(actor.player, null));
|
||||
assertTrue(full.await(30, TimeUnit.SECONDS));
|
||||
blocker.close(); assertTrue(blockerWorker.awaitTermination(30, TimeUnit.SECONDS));
|
||||
f.plugin.getLogger().addHandler(handler);
|
||||
f.playerCommand.execute(actor.player, "fstick", new String[]{"adventure", "tracking"});
|
||||
assertFalse(onCaller.get()); assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
f.playerCommand.execute(actor.player, "fstick", new String[]{"adventure", "tracking", "off"});
|
||||
ReskillEventsNativeTest.await(f, () -> actor.messages.contains("Quest tracking is now off."));
|
||||
assertFalse(fixture.directory.trackingEnabled(quest.id(), actor.core.record.id()));
|
||||
assertTrue(rows(pluginData, "preference.read").isEmpty(), "rejected observation is not exported");
|
||||
held.complete(LocalEventLog.Delivery.REJECTED);
|
||||
f.plugin.setEnabled(false); // Actual packaged cleanup closes the original production outbox.
|
||||
original.drained().toCompletableFuture().get(10, TimeUnit.SECONDS);
|
||||
assertTrue(interrupted.await(1, TimeUnit.SECONDS), "disable must interrupt its own cooperative logger");
|
||||
var ownersField = originalOutbox.getClass().getDeclaredField("OWNERS"); ownersField.setAccessible(true);
|
||||
assertFalse(((Set<?>) ownersField.get(null)).contains(originalOutbox));
|
||||
assertFalse(onCaller.get(), "shutdown cancellation must not move logger I/O onto the native thread");
|
||||
assertTrue(rows(pluginData, "preference.read").isEmpty());
|
||||
try (var files = java.nio.file.Files.list(data.resolve("held/quest-tracking-event-intents"))) {
|
||||
assertEquals(1024, files.count(), "shutdown must not discard unrelated durable intents");
|
||||
}
|
||||
} finally {
|
||||
release.countDown(); f.plugin.getLogger().removeHandler(handler);
|
||||
held.complete(LocalEventLog.Delivery.REJECTED); blocker.close();
|
||||
assertTrue(blockerWorker.awaitTermination(30, TimeUnit.SECONDS));
|
||||
blocker.drained().toCompletableFuture().get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,359 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import games.dmg.fantasystick.events.EventInput;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
/** Real file bytes and owned writer callbacks; only the append channel's failure is injected. */
|
||||
final class LocalEventPartialWriteNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void sameLifetimeRetryPreservesPrefixAndExportsExactPendingRecordAfterPartialWrite() throws Exception {
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-26T12:00:00Z"), ZoneOffset.UTC);
|
||||
var diagnostics = new ArrayList<String>();
|
||||
try (var log = LocalEventLog.open(data, clock,
|
||||
new LocalEventLog.RuntimeInfo("Season 4", "26.2", "test"), 8, 134_217_728L, diagnostics::add)) {
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED, log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
var original = segments().getFirst();
|
||||
var prefix = Files.readAllBytes(original);
|
||||
var input = event(clock, "original\nUnicode ☃");
|
||||
var pending = data.resolve("events/pending/" + input.id() + ".pending");
|
||||
byte[] exact;
|
||||
try (var fault = new AppendFault(log)) {
|
||||
fault.failNext(1);
|
||||
assertEquals(LocalEventLog.Delivery.DURABLE_PENDING,
|
||||
log.emit(input).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
exact = Files.readAllBytes(pending);
|
||||
assertEquals(1, fault.failures.get());
|
||||
assertArrayEquals(concat(prefix, Arrays.copyOf(exact, AppendFault.PARTIAL_BYTES)),
|
||||
Files.readAllBytes(original), "the injected fault must really persist a partial record");
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED,
|
||||
log.emit(event(clock, "next")).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
}
|
||||
assertFalse(Files.exists(pending));
|
||||
var rows = exported(); // Strict JSON parser: concatenating the pending record onto its fragment is RED.
|
||||
var recovered = rows.stream().filter(row -> input.id().toString().equals(row.get("id").getAsString())).toList();
|
||||
assertEquals(1, recovered.size());
|
||||
assertEquals(JsonParser.parseString(new String(exact, java.nio.charset.StandardCharsets.UTF_8)), recovered.getFirst());
|
||||
assertTrue(Files.readAllBytes(original).length >= prefix.length);
|
||||
assertArrayEquals(prefix, Arrays.copyOf(Files.readAllBytes(original), prefix.length));
|
||||
assertTrue(corruptEvidence().stream().anyMatch(bytes ->
|
||||
Arrays.equals(Arrays.copyOf(exact, AppendFault.PARTIAL_BYTES), bytes)));
|
||||
assertTrue(diagnostics.stream().anyMatch(message -> message.contains("durable pending")));
|
||||
}
|
||||
exported();
|
||||
}
|
||||
|
||||
@Test void partialWriteDuringPendingReplayRetainsExactProofUntilASecondRecoverySucceeds() throws Exception {
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-26T12:00:00Z"), ZoneOffset.UTC);
|
||||
try (var log = LocalEventLog.open(data, clock,
|
||||
new LocalEventLog.RuntimeInfo("Season 4", "26.2", "test"), 8, 134_217_728L, ignored -> { })) {
|
||||
log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var input = event(clock, "pending replay");
|
||||
var pending = data.resolve("events/pending/" + input.id() + ".pending");
|
||||
try (var fault = new AppendFault(log)) {
|
||||
fault.failNext(2);
|
||||
assertEquals(LocalEventLog.Delivery.DURABLE_PENDING,
|
||||
log.emit(input).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
var exact = Files.readAllBytes(pending);
|
||||
var unaccepted = event(clock, "not yet accepted");
|
||||
assertEquals(LocalEventLog.Delivery.REJECTED,
|
||||
log.emit(unaccepted).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertArrayEquals(exact, Files.readAllBytes(pending));
|
||||
assertEquals(2, fault.failures.get());
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED,
|
||||
log.emit(event(clock, "retry")).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertFalse(Files.exists(pending));
|
||||
var rows = exported();
|
||||
assertEquals(List.of(JsonParser.parseString(new String(exact, java.nio.charset.StandardCharsets.UTF_8))),
|
||||
rows.stream().filter(row -> input.id().toString().equals(row.get("id").getAsString())).toList());
|
||||
assertTrue(rows.stream().noneMatch(row -> unaccepted.id().toString().equals(row.get("id").getAsString())));
|
||||
assertEquals(2, corruptEvidence().stream().filter(bytes ->
|
||||
Arrays.equals(Arrays.copyOf(exact, AppendFault.PARTIAL_BYTES), bytes)).count());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedQuarantineForceCannotTruncateDamageDeletePendingOrAcknowledgeReplay() throws Exception {
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-26T12:00:00Z"), ZoneOffset.UTC);
|
||||
try (var log = LocalEventLog.open(data, clock,
|
||||
new LocalEventLog.RuntimeInfo("Season 4", "26.2", "test"), 8, 134_217_728L, ignored -> { })) {
|
||||
log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var original = segments().getFirst();
|
||||
var input = event(clock, "quarantine failure");
|
||||
var pending = data.resolve("events/pending/" + input.id() + ".pending");
|
||||
try (var fault = new AppendFault(log)) {
|
||||
fault.failNext(1);
|
||||
assertEquals(LocalEventLog.Delivery.DURABLE_PENDING,
|
||||
log.emit(input).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
var exact = Files.readAllBytes(pending);
|
||||
var damaged = Files.readAllBytes(original);
|
||||
fault.quarantineForceFailures.set(1);
|
||||
assertEquals(LocalEventLog.Delivery.REJECTED,
|
||||
log.emit(event(clock, "repair unavailable")).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals(1, fault.failedQuarantines.get());
|
||||
assertArrayEquals(damaged, Files.readAllBytes(original), "unforced quarantine must not authorize truncation");
|
||||
assertArrayEquals(exact, Files.readAllBytes(pending));
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED,
|
||||
log.emit(event(clock, "repair available")).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertFalse(Files.exists(pending));
|
||||
assertEquals(1, exported().stream().filter(row -> input.id().toString().equals(row.get("id").getAsString())).count());
|
||||
assertTrue(corruptEvidence().stream().anyMatch(bytes ->
|
||||
Arrays.equals(Arrays.copyOf(exact, AppendFault.PARTIAL_BYTES), bytes)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void completeWriteWithFailedForceRetainsPendingAndPermitsOnlyIdenticalAtLeastOnceDuplicates() throws Exception {
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-26T12:00:00Z"), ZoneOffset.UTC);
|
||||
try (var log = LocalEventLog.open(data, clock,
|
||||
new LocalEventLog.RuntimeInfo("Season 4", "26.2", "test"), 8, 134_217_728L, ignored -> { })) {
|
||||
log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var input = event(clock, "uncertain force");
|
||||
var pending = data.resolve("events/pending/" + input.id() + ".pending");
|
||||
try (var fault = new AppendFault(log)) {
|
||||
fault.appendForceFailures.set(1);
|
||||
assertEquals(LocalEventLog.Delivery.DURABLE_PENDING,
|
||||
log.emit(input).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
var exact = Files.readAllBytes(pending);
|
||||
assertEquals(1, fault.failedAppendForces.get());
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED,
|
||||
log.emit(event(clock, "retry")).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
var repeated = exported().stream().filter(row -> input.id().toString().equals(row.get("id").getAsString())).toList();
|
||||
assertEquals(2, repeated.size(), "an uncertain completed append may repeat, never promise exactly once");
|
||||
for (var row : repeated) assertEquals(JsonParser.parseString(new String(exact, java.nio.charset.StandardCharsets.UTF_8)), row);
|
||||
assertFalse(Files.exists(pending));
|
||||
assertTrue(corruptEvidence().isEmpty(), "complete valid records must not be quarantined or rewritten");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredCancelledNativeActionRecoversItsOriginalContextWithoutRestartOrGameplayMutation() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data);
|
||||
var nativeFailures = new NativeFailures(fixture)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
doReturn(null).when(actor.player).getFishHook(); // No hook exists for this ordinary held-slot event.
|
||||
var log = (LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog");
|
||||
log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var before = actor.core.snapshot();
|
||||
var event = new org.bukkit.event.player.PlayerItemHeldEvent(actor.player, 0, 1);
|
||||
event.setCancelled(true);
|
||||
try (var fault = new AppendFault(log)) {
|
||||
fault.onlyContaining = "\"entry_point\":\"PlayerItemHeldEvent\"";
|
||||
fault.failNext(1);
|
||||
fixture.dispatcher.accept(event);
|
||||
fault.worker.submit(() -> { }).get(5, TimeUnit.SECONDS); // Both real registered capture stages settle.
|
||||
assertEquals(1, fault.failures.get());
|
||||
assertTrue(event.isCancelled());
|
||||
assertArrayEquals(before, actor.core.snapshot());
|
||||
var exact = JsonParser.parseString(new String(fault.failedLine, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
assertTrue(exact.get("type").getAsString().endsWith("action.observed"));
|
||||
assertEquals(actor.core.id.toString(), exact.get("playerid").getAsString());
|
||||
assertEquals(actor.core.record.id().toString(), exact.get("characterid").getAsString());
|
||||
var correlated = fault.worker.submit(this::exported).get(5, TimeUnit.SECONDS).stream()
|
||||
.filter(row -> exact.getAsJsonObject("data").get("action_id")
|
||||
.equals(row.getAsJsonObject("data").get("action_id"))).toList();
|
||||
assertEquals(2, correlated.size(), "one observation and one resolution, never a duplicated success");
|
||||
assertEquals(List.of(exact), correlated.stream().filter(row -> row.get("id").equals(exact.get("id"))).toList());
|
||||
var resolved = correlated.stream().filter(row -> row.get("type").getAsString().endsWith("action.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertTrue(resolved.get("cancelled").getAsBoolean());
|
||||
assertEquals("cancelled", resolved.get("outcome").getAsString());
|
||||
nativeFailures.assertClean();
|
||||
assertFalse(Files.exists(data.resolve("events/pending/" + exact.get("id").getAsString() + ".pending")));
|
||||
assertTrue(corruptEvidence().stream().anyMatch(bytes ->
|
||||
Arrays.equals(Arrays.copyOf(fault.failedLine, AppendFault.PARTIAL_BYTES), bytes)));
|
||||
}
|
||||
}
|
||||
assertEquals(1, exported().stream().filter(row -> row.get("type").getAsString().endsWith("game.stopped")).count());
|
||||
}
|
||||
|
||||
private static EventInput event(Clock clock, String marker) {
|
||||
return new EventInput(UUID.randomUUID(), UUID.randomUUID(), clock.instant(), "action.observed", null,
|
||||
Map.of("action_kind", "partial_write_probe", "marker", marker, "outcome", "observed"));
|
||||
}
|
||||
|
||||
private List<Path> segments() throws IOException {
|
||||
try (var files = Files.walk(data.resolve("events"))) {
|
||||
return files.filter(path -> path.getFileName().toString().endsWith(".jsonl")).sorted().toList();
|
||||
}
|
||||
}
|
||||
|
||||
private List<JsonObject> exported() throws IOException {
|
||||
var rows = new ArrayList<JsonObject>();
|
||||
for (var path : segments()) {
|
||||
var bytes = Files.readAllBytes(path);
|
||||
if (bytes.length == 0) continue;
|
||||
assertEquals('\n', bytes[bytes.length - 1], "every exported record must be LF terminated");
|
||||
for (var line : Files.readAllLines(path)) rows.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private List<byte[]> corruptEvidence() throws IOException {
|
||||
var evidence = new ArrayList<byte[]>();
|
||||
try (var files = Files.walk(data.resolve("events"))) {
|
||||
for (var path : files.filter(path -> path.getFileName().toString().contains(".corrupt-")).toList()) {
|
||||
assertFalse(path.getFileName().toString().endsWith(".jsonl"));
|
||||
evidence.add(Files.readAllBytes(path));
|
||||
}
|
||||
}
|
||||
return evidence;
|
||||
}
|
||||
|
||||
private static byte[] concat(byte[] first, byte[] second) {
|
||||
var bytes = Arrays.copyOf(first, first.length + second.length);
|
||||
System.arraycopy(second, 0, bytes, first.length, second.length);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Capture, never suppress, failures from this fixture's native callback boundaries. */
|
||||
private static final class NativeFailures implements AutoCloseable {
|
||||
private final java.util.logging.Logger server;
|
||||
private final java.util.logging.Logger plugin;
|
||||
private final List<java.util.logging.LogRecord> failures = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
private final java.util.logging.Handler handler = new java.util.logging.Handler() {
|
||||
@Override public void publish(java.util.logging.LogRecord record) {
|
||||
if (record.getLevel().intValue() >= java.util.logging.Level.SEVERE.intValue()) failures.add(record);
|
||||
}
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
};
|
||||
private NativeFailures(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
server = fixture.server.getLogger();
|
||||
plugin = fixture.plugin.getLogger();
|
||||
server.addHandler(handler);
|
||||
if (plugin != server) plugin.addHandler(handler);
|
||||
}
|
||||
private void assertClean() {
|
||||
assertTrue(failures.isEmpty(), () -> failures.stream()
|
||||
.map(record -> record.getMessage() + ": " + record.getThrown()).toList().toString());
|
||||
}
|
||||
@Override public void close() {
|
||||
server.removeHandler(handler);
|
||||
if (plugin != server) plugin.removeHandler(handler);
|
||||
assertClean();
|
||||
}
|
||||
}
|
||||
|
||||
/** Scoped to this log's serial worker, never installed globally or on the native/main thread. */
|
||||
private static final class AppendFault implements AutoCloseable {
|
||||
private static final int PARTIAL_BYTES = 47;
|
||||
private final ExecutorService worker;
|
||||
private final MockedStatic<FileChannel> channels;
|
||||
private final AtomicInteger remaining = new AtomicInteger();
|
||||
private final AtomicInteger failures = new AtomicInteger();
|
||||
private final AtomicInteger appendForceFailures = new AtomicInteger();
|
||||
private final AtomicInteger failedAppendForces = new AtomicInteger();
|
||||
private final AtomicInteger quarantineForceFailures = new AtomicInteger();
|
||||
private final AtomicInteger failedQuarantines = new AtomicInteger();
|
||||
private volatile String onlyContaining;
|
||||
private volatile byte[] failedLine;
|
||||
|
||||
private AppendFault(LocalEventLog log) throws Exception {
|
||||
worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(log, "worker");
|
||||
channels = worker.submit(() -> {
|
||||
var scoped = mockStatic(FileChannel.class, CALLS_REAL_METHODS);
|
||||
try {
|
||||
scoped.when(() -> FileChannel.open(any(Path.class), eq(StandardOpenOption.WRITE),
|
||||
eq(StandardOpenOption.APPEND))).thenAnswer(call -> {
|
||||
if (remaining.get() == 0 && appendForceFailures.get() == 0) return call.callRealMethod();
|
||||
Path path = call.getArgument(0);
|
||||
var real = path.getFileSystem().provider().newFileChannel(path,
|
||||
Set.of(StandardOpenOption.WRITE, StandardOpenOption.APPEND));
|
||||
var failing = mock(FileChannel.class);
|
||||
doAnswer(write -> {
|
||||
ByteBuffer source = write.getArgument(0);
|
||||
var complete = new byte[source.remaining()];
|
||||
source.duplicate().get(complete);
|
||||
if ((onlyContaining == null || new String(complete, java.nio.charset.StandardCharsets.UTF_8)
|
||||
.contains(onlyContaining)) && take(remaining)) {
|
||||
failedLine = complete;
|
||||
var partial = source.slice();
|
||||
partial.limit(Math.min(PARTIAL_BYTES, partial.remaining()));
|
||||
while (partial.hasRemaining()) real.write(partial);
|
||||
real.force(true);
|
||||
failures.incrementAndGet();
|
||||
throw new IOException("controlled partial append");
|
||||
}
|
||||
return real.write(source);
|
||||
}).when(failing).write(any(ByteBuffer.class));
|
||||
doAnswer(force -> {
|
||||
if (take(appendForceFailures)) {
|
||||
failedAppendForces.incrementAndGet();
|
||||
throw new IOException("controlled append force failure");
|
||||
}
|
||||
real.force(force.getArgument(0));
|
||||
return null;
|
||||
}).when(failing).force(anyBoolean());
|
||||
doAnswer(close -> { real.close(); return null; }).when(failing).close();
|
||||
return failing;
|
||||
});
|
||||
scoped.when(() -> FileChannel.open(any(Path.class), eq(StandardOpenOption.CREATE_NEW),
|
||||
eq(StandardOpenOption.WRITE))).thenAnswer(call -> {
|
||||
Path path = call.getArgument(0);
|
||||
if (!path.getFileName().toString().contains(".corrupt-") || !take(quarantineForceFailures))
|
||||
return call.callRealMethod();
|
||||
var real = path.getFileSystem().provider().newFileChannel(path,
|
||||
Set.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE));
|
||||
var failing = mock(FileChannel.class);
|
||||
doAnswer(write -> real.write((ByteBuffer) write.getArgument(0))).when(failing).write(any(ByteBuffer.class));
|
||||
doAnswer(force -> {
|
||||
failedQuarantines.incrementAndGet();
|
||||
throw new IOException("controlled quarantine force failure");
|
||||
}).when(failing).force(anyBoolean());
|
||||
doAnswer(close -> { real.close(); return null; }).when(failing).close();
|
||||
return failing;
|
||||
});
|
||||
return scoped;
|
||||
} catch (Throwable failure) {
|
||||
scoped.close();
|
||||
throw failure;
|
||||
}
|
||||
}).get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static boolean take(AtomicInteger count) {
|
||||
return count.getAndUpdate(value -> Math.max(0, value - 1)) > 0;
|
||||
}
|
||||
|
||||
private void failNext(int count) { remaining.set(count); }
|
||||
|
||||
@Override public void close() {
|
||||
try { worker.submit(channels::close).get(5, TimeUnit.SECONDS); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -84,11 +84,10 @@ class FeatureEventOutboxCapacityTest {
|
||||
@Test void outstandingAcknowledgmentsRemainBoundedAndFailureReleasesCapacityForOriginalIdReplay() throws Exception {
|
||||
var held = new CompletableFuture<LocalEventLog.Delivery>();
|
||||
var failures = new CopyOnWriteArrayList<Throwable>();
|
||||
var rejectionReports = new CountDownLatch(2);
|
||||
var delivered = new CopyOnWriteArrayList<EventInput>();
|
||||
var writer = new FeatureEventOutbox(data, "held-intents", Set.of(TYPE), input -> {
|
||||
delivered.add(input); return held;
|
||||
}, error -> { failures.add(error); if (error instanceof java.util.concurrent.RejectedExecutionException) rejectionReports.countDown(); });
|
||||
}, failures::add);
|
||||
var inputs = new java.util.ArrayList<EventInput>();
|
||||
try {
|
||||
for (int i = 0; i < CAPACITY + 1; i++) { var input = event(); inputs.add(input); writer.accepted(input); }
|
||||
@@ -141,9 +140,12 @@ class FeatureEventOutboxCapacityTest {
|
||||
assertEquals(CAPACITY, replayed.size());
|
||||
assertTrue(replayFailures.isEmpty(), replayFailures.toString());
|
||||
try (var files = Files.list(data.resolve("held-intents"))) { assertEquals(List.of(), files.toList()); }
|
||||
writer.accepted(event());
|
||||
assertTrue(rejectionReports.await(5, TimeUnit.SECONDS), "closed handoff diagnosis is also off-thread");
|
||||
assertEquals(2, failures.stream().filter(error -> error instanceof java.util.concurrent.RejectedExecutionException).count(),
|
||||
"closed handoff must remain diagnosed, not silently durable");
|
||||
assertEquals(0, writer.pendingRejectedObservations());
|
||||
var late = event(); writer.accepted(late);
|
||||
assertEquals(1, writer.pendingRejectedObservations(), "late closed rejection remains counted, not falsely logged");
|
||||
assertEquals(1, failures.stream().filter(error -> error instanceof java.util.concurrent.RejectedExecutionException).count(),
|
||||
"terminal diagnostics cannot restart callbacks after their bounded close attempt");
|
||||
assertFalse(Files.exists(data.resolve("held-intents").resolve(late.id() + ".pending")),
|
||||
"closed rejection is never durable/exported");
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,8 @@ class FeatureEventOutboxProgressTest {
|
||||
assertTrue(firstPass.await(30, TimeUnit.SECONDS));
|
||||
assertEquals(CAPACITY - 1, seen.size(), "held callbacks and the replay command share the bound");
|
||||
acknowledge.complete(null);
|
||||
assertTrue(exported.await(30, TimeUnit.SECONDS), "returned capacity must continue durable replay automatically");
|
||||
assertTrue(exported.await(30, TimeUnit.SECONDS), () -> "returned capacity must continue durable replay automatically; submitted="
|
||||
+ seen.size() + "/" + original.size() + "; remaining exports=" + exported.getCount() + "; diagnostics=" + failures);
|
||||
} finally { acknowledge.complete(null); closeOwned(reader); }
|
||||
assertEquals(original.size(), seen.size());
|
||||
assertEquals(Set.copyOf(original), Set.copyOf(seen));
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Real saturation, callback and shutdown boundaries, including their actual resource lifetime. */
|
||||
class FeatureEventOutboxRetirementTest {
|
||||
@TempDir Path data;
|
||||
private static final String TYPE = "action.observed";
|
||||
|
||||
private static EventInput event() {
|
||||
return new EventInput(UUID.randomUUID(), UUID.randomUUID(), Instant.EPOCH, TYPE, null,
|
||||
Map.of("outcome", "observed"));
|
||||
}
|
||||
|
||||
private static Object field(Object owner, String name) throws Exception {
|
||||
var field = FeatureEventOutbox.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field.get(owner);
|
||||
}
|
||||
|
||||
private static ExecutorService maintenance(String name) throws Exception {
|
||||
return (ExecutorService) field(null, name);
|
||||
}
|
||||
|
||||
private static boolean registered(FeatureEventOutbox owner) throws Exception {
|
||||
return ((Set<?>) field(null, "OWNERS")).contains(owner);
|
||||
}
|
||||
|
||||
private static void closeOwned(FeatureEventOutbox owner) throws Exception {
|
||||
owner.close();
|
||||
assertTrue(((ExecutorService) field(owner, "worker")).awaitTermination(30, TimeUnit.SECONDS));
|
||||
owner.drained().toCompletableFuture().get(8, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test void concurrentLateCallsRemainCountedWithoutRevivingCallbacksOrMaintenance() throws Exception {
|
||||
var callbacks = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var owner = new FeatureEventOutbox(data, "late-owner", Set.of(TYPE), input -> {
|
||||
callbacks.incrementAndGet(); return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
|
||||
}, ignored -> callbacks.incrementAndGet());
|
||||
var replays = maintenance("REPLAYS"); var diagnostics = maintenance("DIAGNOSTICS");
|
||||
closeOwned(owner);
|
||||
assertTrue(replays.isShutdown()); assertTrue(diagnostics.isShutdown());
|
||||
var producers = new CompletableFuture<?>[4];
|
||||
for (int i = 0; i < producers.length; i++) producers[i] = CompletableFuture.runAsync(() -> {
|
||||
for (int observation = 0; observation < 2500; observation++) owner.accepted(event());
|
||||
});
|
||||
CompletableFuture.allOf(producers).get(5, TimeUnit.SECONDS);
|
||||
assertEquals(10000, owner.pendingRejectedObservations());
|
||||
assertEquals(0, callbacks.get(), "no sink or diagnostic call may start in a retired lifetime");
|
||||
assertFalse(registered(owner));
|
||||
assertNull(maintenance("REPLAYS")); assertNull(maintenance("DIAGNOSTICS"));
|
||||
assertFalse(Files.exists(data.resolve("late-owner")), "late observations cannot create durable intent files");
|
||||
}
|
||||
|
||||
@Test void nonCooperativeHandlerDoesNotRetainRegistryAndLateCallbacksCannotCloseFreshLifetime() throws Exception {
|
||||
var held = new CompletableFuture<LocalEventLog.Delivery>();
|
||||
var blockerFull = new CountDownLatch(1022);
|
||||
var full = new CountDownLatch(2);
|
||||
var entered = new CountDownLatch(1);
|
||||
var interruptSeen = new CountDownLatch(1);
|
||||
var returned = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var saved = new java.util.concurrent.CopyOnWriteArrayList<EventInput>();
|
||||
var attempted = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var unexpected = new AtomicReference<Throwable>();
|
||||
var owner = new FeatureEventOutbox(data, "non-cooperative", Set.of(TYPE), input -> {
|
||||
saved.add(input); full.countDown(); return held;
|
||||
}, failure -> {
|
||||
if (!(failure instanceof FeatureEventOutbox.HandoffRejection)) { unexpected.set(failure); return; }
|
||||
attempted.incrementAndGet(); entered.countDown();
|
||||
boolean released = false;
|
||||
while (!released) {
|
||||
try { release.await(); released = true; }
|
||||
catch (InterruptedException ignored) { interruptSeen.countDown(); }
|
||||
}
|
||||
returned.countDown();
|
||||
});
|
||||
var blocker = new FeatureEventOutbox(data, "other-durable", Set.of(TYPE), input -> {
|
||||
blockerFull.countDown(); return held;
|
||||
}, unexpected::set);
|
||||
var replays = maintenance("REPLAYS");
|
||||
var diagnostics = maintenance("DIAGNOSTICS");
|
||||
FeatureEventOutbox fresh = null;
|
||||
try {
|
||||
for (int i = 0; i < 1022; i++) blocker.accepted(event());
|
||||
assertTrue(blockerFull.await(30, TimeUnit.SECONDS));
|
||||
closeOwned(blocker); // Unsettled acknowledgments still occupy capacity, without a live maintenance owner.
|
||||
owner.accepted(event()); owner.accepted(event());
|
||||
assertTrue(full.await(30, TimeUnit.SECONDS));
|
||||
owner.accepted(event()); assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
for (int i = 0; i < 5000; i++) owner.accepted(event());
|
||||
assertEquals(1, attempted.get(), "stalled diagnostics cannot queue per-observation callbacks");
|
||||
held.complete(LocalEventLog.Delivery.REJECTED);
|
||||
closeOwned(owner);
|
||||
assertFalse(registered(owner));
|
||||
assertTrue(interruptSeen.await(1, TimeUnit.SECONDS));
|
||||
assertEquals(1, returned.getCount(), "drained is NOT acknowledgment of handler completion/log delivery");
|
||||
assertTrue(replays.isShutdown()); assertTrue(diagnostics.isShutdown());
|
||||
assertFalse(diagnostics.awaitTermination(100, TimeUnit.MILLISECONDS),
|
||||
"an interruption-ignoring handler still retains its executing thread: explicit residual limit");
|
||||
try (var files = Files.list(data.resolve("non-cooperative"))) { assertEquals(2, files.count()); }
|
||||
try (var files = Files.list(data.resolve("other-durable"))) { assertEquals(1022, files.count()); }
|
||||
var exported = new CountDownLatch(saved.size());
|
||||
try (var log = LocalEventLog.open(data.resolve("recovery-stream"), java.time.Clock.systemUTC(),
|
||||
new LocalEventLog.RuntimeInfo("retirement", "native", "test"), 1024, 134_217_728L, ignored -> {})) {
|
||||
assertEquals(LocalEventLog.Delivery.EXPORTED, log.ready().toCompletableFuture().get(10, TimeUnit.SECONDS));
|
||||
fresh = new FeatureEventOutbox(data, "non-cooperative", Set.of(TYPE), input ->
|
||||
log.emit(input).whenComplete((delivery, failure) -> {
|
||||
if (failure == null && delivery == LocalEventLog.Delivery.EXPORTED) exported.countDown();
|
||||
}), unexpected::set);
|
||||
var freshReplays = maintenance("REPLAYS");
|
||||
var freshDiagnostics = maintenance("DIAGNOSTICS");
|
||||
assertNotSame(replays, freshReplays); assertNotSame(diagnostics, freshDiagnostics);
|
||||
for (int i = 0; i < 5000; i++) owner.accepted(event()); // Late native observations cannot reattach the retired owner.
|
||||
owner.close(); assertFalse(registered(owner));
|
||||
assertEquals(10000, owner.pendingRejectedObservations(), "both pre-retirement pending and late rejections remain counted");
|
||||
fresh.replay();
|
||||
assertTrue(exported.await(30, TimeUnit.SECONDS), () -> "remaining exports=" + exported.getCount() + "; failure=" + unexpected.get());
|
||||
release.countDown(); assertTrue(returned.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(diagnostics.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertFalse(freshReplays.isShutdown()); assertFalse(freshDiagnostics.isShutdown());
|
||||
assertTrue(registered(fresh), "old callback completion cannot retire another lifetime");
|
||||
closeOwned(fresh);
|
||||
}
|
||||
var rows = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = Files.walk(data.resolve("recovery-stream/events"))) {
|
||||
for (var file : files.filter(path -> path.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : Files.readAllLines(file)) {
|
||||
var row = com.google.gson.JsonParser.parseString(line).getAsJsonObject();
|
||||
if ((LocalEventLog.TYPE_PREFIX + TYPE).equals(row.get("type").getAsString())) rows.add(row);
|
||||
}
|
||||
}
|
||||
assertEquals(saved.size(), rows.size());
|
||||
var expected = saved.stream().collect(java.util.stream.Collectors.toMap(input -> input.id().toString(), input -> input));
|
||||
assertEquals(expected.keySet(), rows.stream().map(row -> row.get("id").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
for (var row : rows) {
|
||||
var original = expected.get(row.get("id").getAsString());
|
||||
assertEquals(original.time().toString(), row.get("time").getAsString());
|
||||
assertEquals(original.data().get("outcome"), row.getAsJsonObject("data").get("outcome").getAsString());
|
||||
}
|
||||
try (var files = Files.list(data.resolve("non-cooperative"))) { assertEquals(0, files.count()); }
|
||||
assertEquals(1, attempted.get(), "abandoned diagnostics are not claimed flushed by another lifetime");
|
||||
assertNull(unexpected.get());
|
||||
} finally {
|
||||
release.countDown(); held.complete(LocalEventLog.Delivery.REJECTED);
|
||||
closeOwned(owner); closeOwned(blocker); if (fresh != null) closeOwned(fresh);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void closedOwnerInterruptsItsCooperativeHandlerWithoutClosingAnotherOwnersMaintenance() throws Exception {
|
||||
var held = new CompletableFuture<LocalEventLog.Delivery>();
|
||||
var full = new CountDownLatch(1024);
|
||||
var entered = new CountDownLatch(1);
|
||||
var interrupted = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var unexpected = new AtomicReference<Throwable>();
|
||||
var owner = new FeatureEventOutbox(data, "closed-owner", Set.of(TYPE), input -> {
|
||||
full.countDown(); return held;
|
||||
}, failure -> {
|
||||
if (!(failure instanceof FeatureEventOutbox.HandoffRejection)) { unexpected.set(failure); return; }
|
||||
entered.countDown();
|
||||
try { release.await(); }
|
||||
catch (InterruptedException expected) { interrupted.countDown(); Thread.currentThread().interrupt(); }
|
||||
});
|
||||
var otherDelivered = new CountDownLatch(1);
|
||||
var otherReported = new CountDownLatch(1);
|
||||
var otherInterrupted = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var other = new FeatureEventOutbox(data, "live-owner", Set.of(TYPE), input -> {
|
||||
otherDelivered.countDown(); return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
|
||||
}, failure -> {
|
||||
if (failure instanceof FeatureEventOutbox.HandoffRejection) {
|
||||
otherInterrupted.set(Thread.currentThread().isInterrupted()); otherReported.countDown();
|
||||
} else unexpected.set(failure);
|
||||
});
|
||||
var replays = maintenance("REPLAYS");
|
||||
var diagnostics = maintenance("DIAGNOSTICS");
|
||||
try {
|
||||
for (int i = 0; i < 1024; i++) owner.accepted(event());
|
||||
assertTrue(full.await(30, TimeUnit.SECONDS));
|
||||
owner.accepted(event());
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
other.accepted(event()); // Its aggregate waits behind the blocked owner, not on the producer.
|
||||
held.complete(LocalEventLog.Delivery.REJECTED);
|
||||
closeOwned(owner); // Do NOT release the handler: bounded close must interrupt it itself.
|
||||
assertTrue(interrupted.await(1, TimeUnit.SECONDS), "cooperative handler must receive shutdown interruption");
|
||||
assertFalse(registered(owner), "closed owner must leave maintenance even with a stalled handler");
|
||||
assertTrue(registered(other));
|
||||
assertFalse(replays.isShutdown());
|
||||
assertFalse(diagnostics.isShutdown());
|
||||
assertTrue(otherReported.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(otherInterrupted.get(), "our cancellation must not leak into an unrelated handler");
|
||||
other.accepted(event());
|
||||
assertTrue(otherDelivered.await(5, TimeUnit.SECONDS));
|
||||
try (var files = Files.list(data.resolve("closed-owner"))) {
|
||||
assertEquals(1024, files.count(), "rejected sink delivery must retain all durable intents");
|
||||
}
|
||||
assertNull(unexpected.get());
|
||||
} finally {
|
||||
release.countDown(); held.complete(LocalEventLog.Delivery.REJECTED);
|
||||
closeOwned(owner); closeOwned(other);
|
||||
}
|
||||
assertTrue(replays.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(diagnostics.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Clock;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SecondWindHealthPersistenceTest {
|
||||
@TempDir Path data;
|
||||
private static final BigDecimal LIMIT = BigDecimal.valueOf(1024);
|
||||
|
||||
@Test void provisionalTargetCannotLoadAfterRestartButAcknowledgedReconciliationCan() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareSecondWindHealth(record.id(), record.revision(), BigDecimal.valueOf(16), LIMIT)
|
||||
.join().orElseThrow();
|
||||
assertEquals(0, BigDecimal.valueOf(16).compareTo(storage.inspect(record.id()).join().orElseThrow().health()));
|
||||
assertBlocked(storage, record);
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertBlocked(restarted, record);
|
||||
}
|
||||
var settled = storage.reconcileSecondWindHealth(pending, BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
|
||||
assertEquals(record.cooldowns(), settled.cooldowns()); assertBlocked(storage, record);
|
||||
assertTrue(storage.completeSecondWindHealth(pending, settled).join());
|
||||
assertFalse(storage.completeSecondWindHealth(pending, settled).join(), "Duplicate acknowledgment is not new authority");
|
||||
assertEquals(settled, storage.find(record.id()).join().orElseThrow());
|
||||
var next = storage.prepareSecondWindHealth(record.id(), settled.revision(), BigDecimal.valueOf(11), LIMIT)
|
||||
.join().orElseThrow();
|
||||
assertFalse(storage.completeSecondWindHealth(pending, settled).join(), "Old acknowledgment cannot clear a new operation");
|
||||
assertBlocked(storage, record);
|
||||
assertTrue(storage.completeSecondWindHealth(next, next.saved()).join());
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertEquals(0, BigDecimal.valueOf(11).compareTo(restarted.find(record.id()).join().orElseThrow().health()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void newerWriteIsPreservedAndNeitherReconciliationNorCleanupCanClearItsUncertainProof() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareSecondWindHealth(record.id(), record.revision(), BigDecimal.valueOf(16), LIMIT)
|
||||
.join().orElseThrow();
|
||||
var newer = storage.saveHealth(record.id(), pending.saved().revision(), BigDecimal.valueOf(8), LIMIT)
|
||||
.join().orElseThrow();
|
||||
assertTrue(storage.reconcileSecondWindHealth(pending, BigDecimal.valueOf(5), LIMIT).join().isEmpty());
|
||||
assertFalse(storage.completeSecondWindHealth(pending, pending.saved()).join());
|
||||
assertEquals(newer, storage.inspect(record.id()).join().orElseThrow()); assertBlocked(storage, record);
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertBlocked(restarted, record);
|
||||
assertEquals(0, BigDecimal.valueOf(8).compareTo(restarted.inspect(record.id()).join().orElseThrow().health()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReconciliationRetainsDurableBarrierAndDoesNotClaimAnAcknowledgment() throws Exception {
|
||||
var fail = new AtomicBoolean(); var repository = repository(fail); var record = character(); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareSecondWindHealth(record.id(), record.revision(), BigDecimal.valueOf(16), LIMIT)
|
||||
.join().orElseThrow();
|
||||
fail.set(true);
|
||||
assertThrows(CompletionException.class, () -> storage.reconcileSecondWindHealth(pending, BigDecimal.valueOf(5), LIMIT).join());
|
||||
assertBlocked(storage, record);
|
||||
assertEquals(pending.saved(), storage.inspect(record.id()).join().orElseThrow());
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) { assertBlocked(restarted, record); }
|
||||
}
|
||||
|
||||
@Test void failedProvisionalWriteRetainsProofEvenWhenTheOldRecordIsStillVisible() throws Exception {
|
||||
var fail = new AtomicBoolean(); var repository = repository(fail); var record = character(); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
fail.set(true);
|
||||
assertThrows(CompletionException.class, () -> storage.prepareSecondWindHealth(record.id(), record.revision(), BigDecimal.valueOf(16), LIMIT).join());
|
||||
assertEquals(record, storage.inspect(record.id()).join().orElseThrow()); assertBlocked(storage, record);
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) { assertBlocked(restarted, record); }
|
||||
}
|
||||
|
||||
@Test void malformedOrReplacedProofCannotBeSilentlyDeletedByCleanup() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareSecondWindHealth(record.id(), record.revision(), BigDecimal.valueOf(16), LIMIT)
|
||||
.join().orElseThrow();
|
||||
Path proof = data.resolve("second-wind-health").resolve(record.id() + ".pending");
|
||||
Files.writeString(proof, java.util.UUID.randomUUID().toString());
|
||||
assertThrows(CompletionException.class, () -> storage.completeSecondWindHealth(pending, pending.saved()).join());
|
||||
assertTrue(Files.exists(proof)); assertBlocked(storage, record);
|
||||
Files.writeString(proof, "incomplete");
|
||||
assertThrows(CompletionException.class, () -> storage.reconcileSecondWindHealth(pending, BigDecimal.valueOf(5), LIMIT).join());
|
||||
assertTrue(Files.exists(proof)); assertBlocked(storage, record);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void staleInitialRevisionCannotCreateProofOrChangeHealth() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
assertTrue(storage.prepareSecondWindHealth(record.id(), record.revision().add(BigInteger.ONE), BigDecimal.valueOf(16), LIMIT).join().isEmpty());
|
||||
assertEquals(record, storage.find(record.id()).join().orElseThrow());
|
||||
assertFalse(Files.exists(data.resolve("second-wind-health")));
|
||||
}
|
||||
}
|
||||
|
||||
private YamlCharacterRepository repository(AtomicBoolean fail) {
|
||||
return new YamlCharacterRepository(data, (source, target) -> {
|
||||
if (fail.get()) throw new IOException("Injected health replacement failure");
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
});
|
||||
}
|
||||
|
||||
private static void assertBlocked(CharacterStorage storage, CharacterRecord record) {
|
||||
var failure = assertThrows(CompletionException.class, () -> storage.find(record.id()).join());
|
||||
assertInstanceOf(IOException.class, failure.getCause());
|
||||
assertTrue(failure.getCause().getMessage().contains("Second Wind"));
|
||||
}
|
||||
|
||||
private static CharacterRecord character() {
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new java.util.EnumMap<>(base.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(12));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(12));
|
||||
return new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), BigDecimal.TEN, java.util.Set.of("second_wind"),
|
||||
java.util.List.of("second_wind", "", "", "", "", "", ""), base.passives(),
|
||||
java.util.Map.of("second_wind", java.time.Instant.now().plusSeconds(15)), base.restrictedKills(), false,
|
||||
null, null, base.coins(), base.coinsInitialized(), base.rewardReceipts(), base.pvpCoinEscrow(), base.gear(), base.sheetGeneration());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user