Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
// @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<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.`,
|
|
);
|
|
},
|
|
);
|