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.
549 lines
18 KiB
TypeScript
549 lines
18 KiB
TypeScript
// @dune-suite integration
|
|
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,
|
|
);
|
|
});
|