Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,547 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user