// @dune-suite integration 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>): 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, ) => 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>, 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.`, ); }, );