Files
Dune/tests/suk-graduate-recovery.test.ts
Quintine af360c836b Connect Suk Graduate casualty rescues and saved continuation
Implement both rescue bands with legal physical casualty routing, owner controls,
all four bot profiles and durable battle receipts. Preserve paid support, elite
identity, killed/returned leader custody and replay rejection. New Advanced
Atreides skill choices exclude Suk while the KH loss-count ruling is pending;
legacy assigned combinations retain their preflight guard. Public gates remain.

Independent review fixed stale dead trainer assignments and detached rescue
receipts. Final focused Suk/Leader Skills union: 46 passed. Typecheck, lint,
4,452 offline tests, production build and 43 HTTP tests passed on unchanged
source tree 3b76239a46d0ba874299495e64295a5eaaa8f2415c3e9a041bee944bbce50c5a.

Actual browser rescue and refresh retained counters, private hand and pending
winner card cleanup. Two four-profile Basic/Advanced smoke games completed
with JSON restoration and no rejected actions; neither exercised a Suk rescue.
After the outage report the server was healthy. All 360 pre-check rooms and
original seat/recovery records are unchanged; checks added 33 rooms. SQLite
integrity and foreign-key checks pass. No server restart or database reset.
2026-09-13 19:02:32 +10:00

59 lines
3.2 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { resolveSuk, sukBattle } from './suk-graduate-fixture';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('SQLite restart preserves Suk rescue and concurrent submissions save physical counters exactly once', async () => {
const f = unitStore();
const sqlite: DatabaseSync = f.sqlite;
try {
const created = await f.rooms.createRoom('Rescuer', 'emperor', false, []);
const code = created.view.code;
const joined = await f.rooms.joinRoom(code, 'Opponent', 'guild');
const tokens = [created.token!, joined.token!];
const old = await Promise.all(tokens.map((token) => f.rooms.authenticate(code, token)));
for (const [i, auth] of old.entries())
sqlite.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?').run(i ? 'd' : 'a', code, auth.playerId);
const auths = await Promise.all(tokens.map((token) => f.restart().authenticate(code, token)));
const state = resolveSuk(sukBattle());
state.code = code;
state.version = (await f.rooms.readRoom(code)).version;
sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?').run(JSON.stringify(state), state.version, code);
const own = await f.restart().readSeatView(code, auths[0]);
const other = await f.restart().readSeatView(code, auths[1]);
assert.equal(own.decision?.kind, 'sukRescue');
assert.deepEqual(own.decision, other.decision, 'public casualty routing reveals no private hand');
assert.equal('pendingSukRescue' in other, false);
assert.equal(other.players.find((p) => p.id === 'a')!.hand, undefined);
assert.deepEqual(await f.restart().readRoom(code), state);
const decision = own.decision;
if (decision?.kind !== 'sukRescue') throw new Error('Missing rescue');
const choice = decision.options.findIndex((o) => o.normal + o.elite === 3);
let arrivals = 0;
let release!: () => void;
const barrier = new Promise<void>((resolve) => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
const action = { type: 'decision' as const, event: decision.event, choice };
const results = await Promise.allSettled([
f.rooms.act(code, auths[0], state.version, action, clock),
f.restart().act(code, auths[0], state.version, action, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.equal(results.filter((r) => r.status === 'rejected').length, 1);
const after = await f.restart().readRoom(code);
assert.equal(after.version, state.version + 1);
assert.equal(after.pendingSukRescue, null);
assert.equal(after.players[0].reserves, 17);
assert.equal(after.players[0].tanks, 1);
assert.equal(after.players[0].forces['arrakeen:10'], 2);
await assert.rejects(() => f.restart().act(code, auths[0], state.version, action, clock));
assert.deepEqual(await f.restart().readRoom(code), after);
} finally { sqlite.close(); }
});