Files
Dune/tests/autopilot-recovery.test.ts
T
2026-09-08 07:37:52 +10:00

447 lines
16 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
import { setTimeout as pause } from 'node:timers/promises';
import type { GameView } from '../game/engine';
// Real HTTP fixtures only: three human base seats, with two holding setup open.
// The final case observes paced background setup through GET, then retakes control.
// Full delegated completion uses trusted fake-clock production persistence in paced-games.test.ts.
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
const secret = () => randomBytes(32).toString('hex');
type Seat = { id: string; cookie: string };
async function request(
path: string,
body?: unknown,
cookie?: string,
origin?: string,
) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
signal: AbortSignal.timeout(15_000),
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(cookie ? { cookie } : {}),
...(origin ? { origin } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const raw = await response.text();
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
// The development server may reject an Origin before the JSON route runs.
parsed = { error: raw };
}
return {
status: response.status,
data: parsed as GameView & {
view: GameView;
error?: string;
replayed?: boolean;
recoveryConfigured?: boolean;
},
cookie: response.headers.get('set-cookie'),
cache: response.headers.get('cache-control'),
};
}
function seat(result: Awaited<ReturnType<typeof request>>): Seat {
assert.ok(result.cookie, 'Entry must issue a seat cookie');
return { id: result.data.me, cookie: result.cookie.split(';')[0] };
}
const own = (view: GameView) => {
const player = view.players.find((p) => p.id === view.me);
assert.ok(player);
return player;
};
const action = (
path: string,
view: GameView,
owner: Seat,
command: Record<string, unknown>,
) => request(path, { version: view.version, action: command }, owner.cookie);
async function fixture() {
const created = await request('/api/rooms', {
name: 'Autopilot HTTP audit A',
faction: 'atreides',
advanced: false,
expansions: [],
});
assert.equal(created.status, 201);
const path = `/api/rooms/${created.data.code}`;
const a = seat(created);
const joinedB = await request(path, {
type: 'join',
name: 'Autopilot HTTP audit B',
faction: 'emperor',
});
assert.equal(joinedB.status, 200);
const b = seat(joinedB);
const joinedC = await request(path, {
type: 'join',
name: 'Autopilot HTTP audit C',
faction: 'guild',
});
assert.equal(joinedC.status, 200);
const c = seat(joinedC);
let view = joinedC.data;
for (const owner of [a, b, c]) {
const ready = await action(path, view, owner, { type: 'ready' });
assert.equal(ready.status, 200);
view = ready.data;
}
const started = await action(path, view, a, { type: 'start' });
assert.equal(started.status, 200);
assert.equal(started.data.status, 'setup');
assert.equal(started.data.players.length, 3);
assert.ok(started.data.players.every((p) => !p.bot && !p.autopilot));
return { path, a, b, c, view: started.data };
}
const enable = (f: Awaited<ReturnType<typeof fixture>>, difficulty = 'Hard') =>
action(f.path, f.view, f.a, { type: 'setAutopilot', difficulty });
function assertPrivate(view: GameView) {
assert.ok(Array.isArray(own(view).hand));
assert.equal(own(view).hand!.length, view.status === 'setup' ? 0 : 1);
if (view.status === 'setup') assert.equal(view.setupStage, 'traitors');
for (const p of view.players.filter((p) => p.id !== view.me)) {
for (const key of ['hand', 'traitors', 'traitorChoices', 'spice'])
assert.equal(key in p, false, `Another seat exposed ${key}`);
}
for (const key of ['deck', 'spiceDeck', 'tokenHash', 'recoverySecret'])
assert.equal(key in view, false, `Private root field exposed: ${key}`);
}
async function waitForBotPause(path: string, owner: Seat) {
const deadline = Date.now() + 10_000;
let result = await request(path, undefined, owner.cookie);
while (result.data.botsPending && Date.now() < deadline) {
await pause(100);
result = await request(path, undefined, owner.cookie);
}
assert.equal(result.status, 200);
assert.equal(
result.data.botsPending,
false,
'Expected the server to stop at remaining human setup decisions',
);
return result;
}
void test('autopilot HTTP: authentication, cross-origin and target injection reject without changing the room', async () => {
const f = await fixture();
const body = {
version: f.view.version,
action: { type: 'setAutopilot', difficulty: 'Hard' },
};
assert.equal((await request(f.path, body)).status, 409);
assert.equal(
(await request(f.path, body, `dune_${f.view.code}=${secret()}`)).status,
409,
);
assert.equal(
(await request(f.path, body, f.a.cookie, 'https://invalid.example')).status,
403,
);
for (const targetField of ['target', 'playerId', 'player', 'id']) {
const rejected = await action(f.path, f.view, f.b, {
...body.action,
[targetField]: f.a.id,
});
assert.equal(rejected.status, 409);
assert.match(rejected.data.error ?? '', /only for your own seat/i);
}
for (const difficulty of ['Impossible', '', false, 0]) {
const rejected = await action(f.path, f.view, f.a, {
type: 'setAutopilot',
difficulty,
});
assert.equal(rejected.status, 409);
}
const refreshed = await request(f.path, undefined, f.a.cookie);
assert.equal(refreshed.status, 200);
assert.deepEqual(refreshed.data, f.view);
});
void test('autopilot HTTP: mode is public, setup is paced, and private cards survive refresh', async () => {
const f = await fixture();
const before = own(f.view);
const enabled = await enable(f);
assert.equal(enabled.status, 200);
assert.equal(enabled.data.status, 'setup');
assert.equal(enabled.data.version, f.view.version + 1);
assert.equal(own(enabled.data).autopilot, 'Hard');
assert.equal(own(enabled.data).bot, undefined);
assert.deepEqual(own(enabled.data).hand, before.hand);
assert.equal(
own(enabled.data).traitors!.length,
0,
'Opt-in must not execute an immediate AI action',
);
assert.ok(enabled.data.botNextActionAt);
const settled = await waitForBotPause(f.path, f.a);
assert.equal(own(settled.data).traitors!.length, 1);
assert.equal(own(settled.data).traitorChoices!.length, 0);
assert.ok(settled.data.version > enabled.data.version);
for (const owner of [f.a, f.b, f.c]) {
const refreshed = await request(f.path, undefined, owner.cookie);
assert.equal(refreshed.status, 200);
assert.equal(refreshed.cache, 'no-store');
assert.equal(refreshed.data.me, owner.id);
assert.equal(refreshed.data.status, 'setup');
assert.equal(refreshed.data.version, settled.data.version);
assert.equal(
refreshed.data.players.find((p) => p.id === f.a.id)!.autopilot,
'Hard',
);
assertPrivate(refreshed.data);
if (owner.id === f.a.id) assert.deepEqual(refreshed.data, settled.data);
else {
assert.equal(own(refreshed.data).traitorChoices!.length, 4);
assert.equal(own(refreshed.data).traitors!.length, 0);
}
}
const back = await action(f.path, settled.data, f.a, {
type: 'setAutopilot',
difficulty: null,
});
assert.equal(back.status, 200);
for (const owner of [f.b, f.c]) {
const current = await request(f.path, undefined, owner.cookie);
assert.equal(current.status, 200);
const chosen = await action(f.path, current.data, owner, {
type: 'traitor',
leader: own(current.data).traitorChoices![0],
});
assert.equal(chosen.status, 200);
}
for (const owner of [f.a, f.b, f.c]) {
const dealt = await request(f.path, undefined, owner.cookie);
assert.equal(dealt.status, 200);
assert.equal(dealt.data.status, 'playing');
assertPrivate(dealt.data);
const refreshed = await request(f.path, undefined, owner.cookie);
assert.equal(refreshed.status, 200);
assert.deepEqual(refreshed.data, dealt.data);
}
});
void test('autopilot HTTP: owner game decisions are blocked while advancement and take-back remain available', async () => {
const f = await fixture();
const enabled = await enable(f, 'Medium');
assert.equal(enabled.status, 200);
const rejected = await action(f.path, enabled.data, f.a, {
type: 'traitor',
leader: own(f.view).traitorChoices![0],
});
assert.equal(rejected.status, 409);
assert.match(rejected.data.error ?? '', /take back control/i);
const unchanged = await request(f.path, undefined, f.a.cookie);
assert.deepEqual(unchanged.data, enabled.data);
const advanced = await action(f.path, unchanged.data, f.a, {
type: 'advanceBots',
});
assert.equal(advanced.status, 200);
assert.equal(advanced.data.status, 'setup');
const back = await action(f.path, advanced.data, f.a, {
type: 'setAutopilot',
difficulty: null,
});
assert.equal(back.status, 200);
assert.equal(own(back.data).autopilot, undefined);
assert.equal(own(back.data).bot, undefined);
assert.deepEqual(own(back.data).hand, own(f.view).hand);
assert.equal(back.data.status, 'setup');
});
void test('autopilot HTTP: competing mode writes commit once and stale take-back cannot override the winner', async () => {
const f = await fixture();
const raced = await Promise.all([enable(f, 'Easy'), enable(f, 'Brutal')]);
assert.deepEqual(
raced.map((r) => r.status).sort((a, b) => a - b),
[200, 409],
);
const winner = raced.find((r) => r.status === 200)!;
assert.equal(winner.data.version, f.view.version + 1);
const stale = await action(f.path, f.view, f.a, {
type: 'setAutopilot',
difficulty: null,
});
assert.equal(stale.status, 409);
const refreshed = await waitForBotPause(f.path, f.a);
assert.equal(refreshed.status, 200);
assert.equal(own(refreshed.data).autopilot, own(winner.data).autopilot);
assert.ok(refreshed.data.version > winner.data.version);
assert.equal(own(refreshed.data).traitors!.length, 1);
assert.equal(refreshed.data.status, 'setup');
});
void test('autopilot HTTP: recovery rotates credentials while delegated; recovered owner takes back and submits a human storm dial', async () => {
const f = await fixture();
const queued = await enable(f);
assert.equal(queued.status, 200);
const enabled = await waitForBotPause(f.path, f.a);
const recoverySecret = secret();
const configured = await request(
f.path + '/control',
{
type: 'setRecoveryKey',
version: enabled.data.version,
recoverySecret,
},
f.a.cookie,
);
assert.equal(configured.status, 200);
assert.equal(configured.data.recoveryConfigured, true);
assert.equal(own(configured.data.view).autopilot, 'Hard');
const newSessionToken = secret();
const body = {
type: 'recoverSeat',
playerId: f.a.id,
recoverySecret,
operationId: randomUUID(),
newSessionToken,
};
const recovered = await request(f.path + '/control', body);
assert.equal(recovered.status, 200);
assert.equal(recovered.data.replayed, false);
assert.equal(recovered.cache, 'no-store');
assert.ok(recovered.cookie?.includes('HttpOnly'));
assert.ok(recovered.cookie?.includes('SameSite=Strict'));
assert.ok(recovered.cookie?.includes(`Path=${f.path}`));
const recoveredSeat = {
id: f.a.id,
cookie: recovered.cookie!.split(';')[0],
};
assert.equal(recoveredSeat.cookie === f.a.cookie, false);
assert.deepEqual(
{ ...recovered.data.view, version: 0 },
{ ...configured.data.view, version: 0 },
);
assert.equal(recovered.data.view.version, configured.data.view.version + 1);
const replay = await request(f.path + '/control', body);
assert.equal(replay.status, 200);
assert.equal(replay.data.replayed, true);
assert.equal(replay.cookie === recovered.cookie, true);
assert.deepEqual(replay.data.view, recovered.data.view);
assert.equal((await request(f.path, undefined, f.a.cookie)).status, 409);
const staleOwner = await action(f.path, recovered.data.view, f.a, {
type: 'setAutopilot',
difficulty: null,
});
assert.equal(staleOwner.status, 409);
const refreshed = await request(f.path, undefined, recoveredSeat.cookie);
assert.equal(refreshed.status, 200);
assert.deepEqual(refreshed.data, recovered.data.view);
for (const view of [
refreshed.data,
(await request(f.path, undefined, f.b.cookie)).data,
]) {
assertPrivate(view);
const serialized = JSON.stringify(view);
assert.equal(serialized.includes(recoverySecret), false);
assert.equal(serialized.includes(newSessionToken), false);
}
const back = await action(f.path, refreshed.data, recoveredSeat, {
type: 'setAutopilot',
difficulty: null,
});
assert.equal(back.status, 200);
assert.equal(own(back.data).autopilot, undefined);
assert.equal(back.data.me, f.a.id);
assert.deepEqual(own(back.data).hand, own(f.view).hand);
// Finish only the two held human choices, then prove restored gameplay authority.
for (const owner of [f.b, f.c]) {
const current = await request(f.path, undefined, owner.cookie);
const chosen = await action(f.path, current.data, owner, {
type: 'traitor',
leader: own(current.data).traitorChoices![0],
});
assert.equal(chosen.status, 200);
}
const playing = await request(f.path, undefined, recoveredSeat.cookie);
assert.equal(playing.data.status, 'playing');
assert.equal(playing.data.phase, 0);
assertPrivate(playing.data);
assert.ok(playing.data.stormDialers.includes(f.a.id));
const dialed = await action(f.path, playing.data, recoveredSeat, {
type: 'stormDial',
amount: 0,
});
assert.equal(dialed.status, 200);
assert.ok(dialed.data.stormSubmitted.includes(f.a.id));
assert.equal(own(dialed.data).autopilot, undefined);
assertPrivate(dialed.data);
assert.deepEqual(own(dialed.data).hand, own(playing.data).hand);
const reconnected = await request(f.path, undefined, recoveredSeat.cookie);
assert.equal(reconnected.status, 200);
assert.deepEqual(reconnected.data, dialed.data);
});
void test(
'autopilot HTTP: all delegated humans progress at persisted intervals through GET recovery, then can take back control',
{ timeout: 20_000 },
async (t) => {
const f = await fixture();
let view = f.view;
for (const [owner, difficulty] of [
[f.a, 'Hard'],
[f.b, 'Medium'],
[f.c, 'Brutal'],
] as const) {
const result = await action(f.path, view, owner, {
type: 'setAutopilot',
difficulty,
});
assert.equal(result.status, 200);
view = result.data;
}
const startingVersion = view.version;
let due = view.botNextActionAt!;
assert.ok(due);
let transitions = 0;
const deadline = Date.now() + 12_000;
while (view.status === 'setup' && Date.now() < deadline) {
await pause(100);
const read = await request(f.path, undefined, f.a.cookie);
assert.equal(read.status, 200);
assertPrivate(read.data);
assert.ok(read.data.version >= view.version);
if (read.data.botNextActionAt && read.data.botNextActionAt > due) {
assert.ok(
read.data.botNextActionAt - due >= 1500,
'The server may not shorten the persisted AI interval',
);
due = read.data.botNextActionAt;
transitions++;
}
view = read.data;
}
assert.equal(view.status, 'playing');
assert.ok(view.version >= startingVersion + 3);
assert.ok(transitions >= 2);
for (const owner of [f.a, f.b, f.c]) {
let accepted = false;
for (let attempt = 0; attempt < 8 && !accepted; attempt++) {
const latest = await request(f.path, undefined, owner.cookie);
const back = await action(f.path, latest.data, owner, {
type: 'setAutopilot',
difficulty: null,
});
if (back.status === 409) continue;
assert.equal(back.status, 200);
assert.equal(own(back.data).autopilot, undefined);
accepted = true;
}
assert.ok(accepted, 'A human owner must be able to retake control');
}
const stopped = await request(f.path, undefined, f.a.cookie);
assert.equal(stopped.data.botsPending, false);
assert.equal(stopped.data.botNextActionAt, null);
assert.ok(stopped.data.players.every((p) => !p.bot && !p.autopilot));
t.diagnostic(
`${transitions} visible paced AI steps; full delegated-game completion uses trusted fake-clock production persistence in paced-games.test.ts.`,
);
},
);