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

548 lines
18 KiB
TypeScript

import test from 'node:test';
import type { GameView } from '../game/engine';
import assert from 'node:assert/strict';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
async function request(path: string, body?: unknown, cookie?: string) {
const r = await fetch(base + path, {
method: body ? 'POST' : 'GET',
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
const data = (await r.json()) as GameView & { error?: string };
return {
status: r.status,
data,
cookie: r.headers.get('set-cookie')?.split(';')[0] ?? cookie,
};
}
// Each response decision belongs to its authenticated viewer. Public response
// projections intentionally do not disclose which other seats already allowed it.
async function allowResponses(path: string, seats: { cookie?: string }[]) {
let game = (await request(path, undefined, seats[0].cookie)).data;
for (let steps = 0; game.response && steps < 64; steps++) {
let submitted = false;
for (const seat of seats) {
const read = await request(path, undefined, seat.cookie);
assert.equal(read.status, 200, JSON.stringify(read.data));
game = read.data;
if (!game.response) return game; // Already completed by authoritative normalization.
assert.ok(game.response.passed.every((id) => id === game.me));
const controls = game.responseControls;
assert.ok(controls, 'A pending response supplies private controls.');
const ownHand = game.players.find((p) => p.id === game.me)!.hand!;
assert.ok(
controls.cancelCards.every((id) =>
ownHand.some((card) => card.id === id),
),
);
if (!controls.cancelCards.length || controls.hasPassed) continue;
const result = await request(
path,
{
version: game.version,
action: { type: 'passResponse' },
},
seat.cookie,
);
assert.equal(result.status, 200, JSON.stringify(result.data));
game = result.data;
submitted = true;
break;
}
if (!submitted) {
const refreshed = await request(path, undefined, seats[0].cookie);
assert.equal(refreshed.status, 200, JSON.stringify(refreshed.data));
game = refreshed.data;
assert.equal(
game.response,
null,
'A response with no remaining eligible viewer must settle automatically.',
);
}
}
assert.equal(game.response, null, 'Response chain must terminate.');
return game;
}
void test('two independent seats, reconnect, private state and concurrent updates', async () => {
const a = await request('/api/rooms', {
name: 'Integration Atreides',
faction: 'atreides',
advanced: false,
expansions: [],
});
assert.equal(a.status, 201, JSON.stringify(a.data));
assert.ok(a.cookie);
const path = `/api/rooms/${a.data.code}`;
const b = await request(path, {
type: 'join',
name: 'Integration Harkonnen',
faction: 'harkonnen',
});
assert.equal(b.status, 200, JSON.stringify(b.data));
assert.notEqual(a.cookie, b.cookie);
const stranger = await request(path);
assert.equal(stranger.status, 409);
const impersonator = await request(
path,
{ version: 1, action: { type: 'ready' } },
'dune_invalid=bad',
);
assert.equal(impersonator.status, 409);
let read = await request(path, undefined, a.cookie);
assert.equal(read.data.players.length, 2);
const concurrent = await Promise.all([
request(
path,
{ version: read.data.version, action: { type: 'ready' } },
a.cookie,
),
request(
path,
{ version: read.data.version, action: { type: 'ready' } },
b.cookie,
),
]);
assert.deepEqual(
concurrent.map((x) => x.status).sort((a, b) => a - b),
[200, 409],
);
for (const seat of [a, b]) {
read = await request(path, undefined, seat.cookie);
if (!read.data.players.find((p) => p.id === read.data.me)!.ready) {
const next = await request(
path,
{ version: read.data.version, action: { type: 'ready' } },
seat.cookie,
);
assert.equal(next.status, 200);
}
}
read = await request(path, undefined, a.cookie);
const start = await request(
path,
{ version: read.data.version, action: { type: 'start' } },
a.cookie,
);
assert.equal(start.status, 200, JSON.stringify(start.data));
assert.equal(start.data.status, 'setup');
const own = start.data.players.find((p) => p.id === start.data.me),
other = start.data.players.find((p) => p.id !== start.data.me);
assert.equal(start.data.setupStage, 'traitors');
assert.equal(own!.hand!.length, 0);
assert.equal(other!.hand, undefined);
assert.equal(other!.spice, undefined);
assert.equal(other!.traitors, undefined);
assert.equal('deck' in start.data, false);
const resumed = await request(path, undefined, b.cookie);
assert.equal(
resumed.data.players.find((p) => p.id === resumed.data.me)!.hand!.length,
0,
);
const chosen = await request(
path,
{
version: resumed.data.version,
action: { type: 'traitor', leader: own!.traitorChoices![0] },
},
a.cookie,
);
assert.equal(chosen.status, 200);
assert.equal(chosen.data.status, 'playing');
assert.equal(
chosen.data.players.find((p) => p.id === a.data.me)!.hand!.length,
1,
);
for (const seat of [a, b]) {
const dealt = await request(path, undefined, seat.cookie);
assert.equal(dealt.status, 200);
assert.equal(
dealt.data.players.find((p) => p.id === dealt.data.me)!.hand!.length,
seat === a ? 1 : 2,
);
for (const p of dealt.data.players.filter((p) => p.id !== dealt.data.me)) {
assert.equal(p.hand, undefined);
assert.equal(p.traitors, undefined);
assert.equal(p.spice, undefined);
}
const refreshed = await request(path, undefined, seat.cookie);
assert.equal(refreshed.status, 200);
assert.deepEqual(refreshed.data, dealt.data);
}
const invalid = await request(
path,
{
version: chosen.data.version,
action: { type: 'ship', amount: -5, territory: 'arrakeen', sector: 10 },
},
a.cookie,
);
assert.equal(invalid.status, 409);
const after = await request(path, undefined, a.cookie);
assert.equal(after.data.version, chosen.data.version);
let game = after.data;
const play = async (id: string, action: object) => {
const seat = id === a.data.me ? a : b;
const result = await request(
path,
{ version: game.version, action },
seat.cookie,
);
assert.equal(result.status, 200, JSON.stringify(result.data));
game = result.data;
};
for (const id of game.stormDialers)
await play(id, { type: 'stormDial', amount: 0 });
for (let steps = 0; game.phase !== 5 && steps < 60; steps++) {
if (game.response) game = await allowResponses(path, [a, b]);
else if (game.phase === 3)
await play(game.auction!.active, { type: 'passBid' });
else
await play(game.players.find((p) => !game.ready.includes(p.id))!.id, {
type: 'ready',
});
}
assert.equal(game.phase, 5);
game = await allowResponses(path, [a, b]);
while (game.phase === 5) {
const id = game.active!;
if (id === b.data.me)
await play(id, {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 1,
});
await play(id, { type: 'endMovement' });
}
assert.equal(game.phase, 6);
await play(game.active!, {
type: 'chooseBattle',
territory: 'arrakeen',
target: game.active === a.data.me ? b.data.me : a.data.me,
});
assert.equal(game.battle!.preparation!.owner, a.data.me);
await play(a.data.me, { type: 'prescience', field: 'dial' });
game = await allowResponses(path, [a, b]);
await play(b.data.me, { type: 'prescienceAnswer', value: 0 });
const foresight = await request(path, undefined, a.cookie);
assert.equal(foresight.data.battle!.insight!.value, 0);
assert.deepEqual(foresight.data.battle!.plans, {});
const changedElement = await request(
path,
{
version: game.version,
action: { type: 'battlePlan', dial: 1, leader: 'harkonnen-0' },
},
b.cookie,
);
assert.equal(changedElement.status, 409);
await play(b.data.me, { type: 'battlePlan', dial: 0, leader: 'harkonnen-0' });
const privatePlan = await request(path, undefined, a.cookie);
assert.equal(privatePlan.data.battle!.plans[b.data.me], undefined);
await play(a.data.me, { type: 'battlePlan', dial: 0, leader: 'atreides-0' });
assert.equal(game.battle!.revealed, true);
await play(a.data.me, { type: 'traitorCall', call: false });
await play(b.data.me, { type: 'traitorCall', call: false });
assert.equal(game.battle, null);
assert.equal(game.phase, 7);
});
void test('six seats complete setup and share an authoritative BG reaction across reconnects', async () => {
const factions = [
'atreides',
'harkonnen',
'emperor',
'fremen',
'guild',
'beneGesserit',
];
const seats = [
await request('/api/rooms', {
name: 'Six-seat Atreides',
faction: factions[0],
advanced: false,
expansions: [],
}),
];
assert.equal(seats[0].status, 201);
const path = `/api/rooms/${seats[0].data.code}`;
for (const faction of factions.slice(1)) {
const seat = await request(path, {
type: 'join',
name: `Six-seat ${faction}`,
faction,
});
assert.equal(seat.status, 200, JSON.stringify(seat.data));
seats.push(seat);
}
assert.equal(new Set(seats.map((s) => s.cookie)).size, 6);
let game = (await request(path, undefined, seats[0].cookie)).data;
const play = async (id: string, action: object) => {
const seat = seats.find((s) => s.data.me === id)!;
const response = await request(
path,
{ version: game.version, action },
seat.cookie,
);
assert.equal(response.status, 200, JSON.stringify(response.data));
game = response.data;
return game;
};
for (const seat of seats) await play(seat.data.me, { type: 'ready' });
await play(seats[0].data.me, { type: 'start' });
assert.equal(game.setupStage, 'prediction');
for (const seat of seats) {
const read = await request(path, undefined, seat.cookie);
assert.equal(read.status, 200);
const me = read.data.players.find((p) => p.id === read.data.me)!;
assert.deepEqual(me.hand, []);
assert.deepEqual(me.traitors, []);
assert.deepEqual(me.traitorChoices, []);
}
await play(seats[5].data.me, {
type: 'predict',
faction: 'atreides',
turn: 3,
});
for (const seat of seats) {
const read = await request(path, undefined, seat.cookie);
const me = read.data.players.find((p) => p.id === read.data.me)!;
if (me.traitorChoices?.length)
await play(me.id, { type: 'traitor', leader: me.traitorChoices[0] });
}
assert.equal(game.setupStage, 'forces');
await play(seats[3].data.me, {
type: 'fremenSetup',
placements: {
sietch_tabr: 10,
false_wall_south: 0,
false_wall_west: 0,
},
});
assert.equal(game.status, 'playing');
for (const id of game.stormDialers)
await play(id, { type: 'stormDial', amount: 0 });
for (let steps = 0; game.phase !== 5 && steps < 80; steps++) {
if (game.response) game = await allowResponses(path, seats);
else if (game.phase === 3)
await play(game.auction!.active, { type: 'passBid' });
else
await play(game.players.find((p) => !game.ready.includes(p.id))!.id, {
type: 'ready',
});
}
assert.equal(game.phase, 5);
game = await allowResponses(path, seats);
for (let step = 0; step < 6; step++) {
const active = game.players.find((p) => p.id === game.active)!;
if (!['fremen', 'beneGesserit'].includes(active.faction)) break;
await play(active.id, { type: 'endMovement' });
}
const shipper = game.active!;
await play(shipper, {
type: 'ship',
territory: 'polar_sink',
sector: 0,
amount: 1,
});
assert.equal(game.decision?.kind, 'advisor');
game = await allowResponses(path, seats);
const bg = seats.find(
(s) =>
s.data.players.find((p) => p.id === s.data.me)?.faction ===
'beneGesserit',
)!;
const reconnected = await request(path, undefined, bg.cookie);
assert.equal(reconnected.data.decision?.player, bg.data.me);
assert.equal(
reconnected.data.players.find((p) => p.id === shipper)!.hand,
undefined,
);
const wrong = await request(
path,
{ version: game.version, action: { type: 'decision', accept: true } },
seats.find((s) => s.data.me === shipper)!.cookie,
);
assert.equal(wrong.status, 409);
const concurrent = await Promise.all(
[true, false].map((accept) =>
request(
path,
{ version: game.version, action: { type: 'decision', accept } },
bg.cookie,
),
),
);
assert.deepEqual(
concurrent.map((r) => r.status).sort((a, b) => a - b),
[200, 409],
);
game = concurrent.find((r) => r.status === 200)!.data;
assert.equal(game.decision, null);
game = await allowResponses(path, seats);
await play(shipper, { type: 'endMovement' });
const readers = await Promise.all(
seats.map((s) => request(path, undefined, s.cookie)),
);
assert.ok(
readers.every(
(r) => r.data.version === game.version && r.data.decision === null,
),
);
assert.ok(
readers.every((r) =>
r.data.players.every(
(p) =>
p.id === r.data.me ||
(p.hand === undefined &&
p.spice === undefined &&
p.traitors === undefined),
),
),
);
});
for (const techEnabled of [false, true])
void test(`host adds AI seats; paced ${techEnabled ? 'tech-token' : 'base'} setup progresses through HTTP without exposing bot secrets`, async () => {
const seat = await request('/api/rooms', {
name: 'Human host',
faction: 'atreides',
advanced: false,
expansions: [],
});
assert.equal(seat.status, 201);
const path = `/api/rooms/${seat.data.code}`;
let game = seat.data;
const play = async (action: object) => {
const result = await request(
path,
{ version: game.version, action },
seat.cookie,
);
assert.equal(result.status, 200, JSON.stringify(result.data));
game = result.data;
for (const p of game.players.filter((p) => p.bot)) {
assert.equal(p.hand, undefined);
assert.equal(p.traitors, undefined);
assert.equal(p.spice, undefined);
}
};
for (const [faction, difficulty] of [
['harkonnen', 'Easy'],
['fremen', 'Medium'],
['emperor', 'Hard'],
['guild', 'Brutal'],
['beneGesserit', 'Hard'],
])
await play({ type: 'addBot', faction, difficulty });
assert.equal(game.players.length, 6);
if (techEnabled) {
await play({ type: 'techTokens', enabled: true });
const reconnected = await request(path, undefined, seat.cookie);
assert.deepEqual(reconnected.data.techTokens, game.techTokens);
assert.equal(game.players.find((p) => p.id === game.me)!.ready, false);
}
await play({ type: 'ready' });
await play({ type: 'start' });
const queued = game;
assert.equal(queued.botsPending, true);
assert.ok(queued.botNextActionAt! > 0);
const deadline = Date.now() + 10_000;
while (game.version === queued.version && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
const next = await request(path, undefined, seat.cookie);
assert.equal(next.status, 200);
game = next.data;
}
assert.ok(
game.version > queued.version,
'Native AI must progress via the paced server worker',
);
assert.equal(game.players.filter((p) => p.bot).length, 5);
for (const p of game.players.filter((p) => p.bot)) {
assert.equal(p.hand, undefined);
assert.equal(p.traitors, undefined);
assert.equal(p.spice, undefined);
}
if (techEnabled) assert.deepEqual(game.techTokens, queued.techTokens);
// Full base/tech completion now runs against this same production room module
// with a trusted fake clock in paced-games.test.ts. No HTTP fast mode exists.
});
void test('player circles persist across reconnect and competing seat choices cannot share a circle', async () => {
const a = await request('/api/rooms', {
name: 'Circle host',
faction: 'atreides',
advanced: false,
expansions: [],
});
assert.equal(a.status, 201);
const path = `/api/rooms/${a.data.code}`;
const b = await request(path, {
type: 'join',
name: 'Circle guest',
faction: 'harkonnen',
});
assert.equal(b.status, 200);
let current = await request(path, undefined, a.cookie);
const seats = [a, b];
assert.deepEqual(current.data.playerPositions, {
[a.data.me]: 1,
[b.data.me]: 2,
});
const conflict = await Promise.all(
seats.map((seat) =>
request(
path,
{
version: current.data.version,
action: { type: 'seatPosition', position: 6 },
},
seat.cookie,
),
),
);
assert.deepEqual(
conflict.map((result) => result.status).sort((a, b) => a - b),
[200, 409],
);
const winnerIndex = conflict.findIndex((result) => result.status === 200);
const winner = seats[winnerIndex];
const loser = seats[1 - winnerIndex];
current = await request(path, undefined, loser.cookie);
assert.equal(current.data.playerPositions[winner.data.me], 6);
assert.equal(new Set(Object.values(current.data.playerPositions)).size, 2);
const stored = structuredClone(current.data.playerPositions);
const occupied = await request(
path,
{
version: current.data.version,
action: { type: 'seatPosition', position: 6 },
},
loser.cookie,
);
assert.equal(occupied.status, 409);
const forged = await request(
path,
{
version: current.data.version,
action: { type: 'seatPosition', position: 5, target: winner.data.me },
},
loser.cookie,
);
assert.equal(forged.status, 409);
const recovered = await request(path, undefined, winner.cookie);
assert.equal(recovered.data.version, current.data.version);
assert.deepEqual(recovered.data.playerPositions, stored);
assert.equal('deck' in recovered.data, false);
assert.equal('discard' in recovered.data, false);
assert.equal(
recovered.data.players.find((p) => p.id === loser.data.me)?.hand,
undefined,
);
});