Prototype two-player four-faction setup

This commit is contained in:
Quintine committed 2026-10-27 23:48:08 +11:00
1 parent 70ef142577
commit 24255905ed
28 files changed
+1091 -73

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
TwoPlayerControllersError,
addTwoPlayerControlledSeat as add,
controllerStrongholdTarget as target,
createTwoPlayerControllers as create,
physicalControllerCount as count,
validateTwoPlayerControllersComplete as complete,
validateTwoPlayerControllersLobby as lobby,
type TwoPlayerControllers,
} from '../game/two-player-controllers';
const seats = ['atreides', 'harkonnen', 'fremen', 'guild'];
function assembled(): TwoPlayerControllers {
let state = create();
state = add(state, seats, 'atreides', 'atreides');
state = add(state, seats, 'atreides', 'fremen');
state = add(state, seats, 'harkonnen', 'harkonnen');
return add(state, seats, 'harkonnen', 'guild');
}
void test('lobby additions create two distinct primary controllers without mutating inputs', () => {
const empty = create(), first = add(empty, seats, 'atreides', 'atreides');
const prior = structuredClone(first);
const second = add(first, seats, 'atreides', 'fremen');
assert.deepEqual(empty, { version: 1, controllers: [] });
assert.deepEqual(first, prior);
assert.notEqual(second.controllers, first.controllers);
assert.notEqual(second.controllers[0].seats, first.controllers[0].seats);
lobby(second, seats);
assert.throws(() => complete(second, seats), TwoPlayerControllersError);
const ready = assembled();
complete(ready, seats);
assert.deepEqual(ready.controllers, [
{ id: 'atreides', seats: ['atreides', 'fremen'] },
{ id: 'harkonnen', seats: ['harkonnen', 'guild'] },
]);
});
void test('full mapping counts two people and keeps faction alliances separate', () => {
const state = assembled();
assert.equal(count(seats, state), 2);
assert.equal(target(seats, state, false), 4);
assert.equal(target(seats, state, true), 4);
assert.deepEqual(state.controllers[0].seats, ['atreides', 'fremen']);
// Without the explicit mode, four faction seats remain four players.
assert.equal(count(seats), 4);
assert.equal(target(seats, undefined, false), 3);
assert.equal(target(seats, undefined, true), 4);
assert.equal(target(seats.slice(0, 2), null, false), 4);
assert.equal(count(['a', 'b', 'c', 'd', 'e', 'f']), 6);
});
void test('lobby mapping rejects duplicate, foreign, missing and forged primary assignments', () => {
for (const value of [
{ version: 1, controllers: [{ id: 'atreides', seats: ['atreides', 'atreides'] }] },
{ version: 1, controllers: [{ id: 'atreides', seats: ['fremen'] }] },
{ version: 1, controllers: [{ id: 'atreides', seats: ['atreides', 'foreign'] }] },
{ version: 1, controllers: [
{ id: 'atreides', seats: ['atreides', 'fremen'] },
{ id: 'harkonnen', seats: ['harkonnen', 'fremen'] },
] },
{ version: 1, controllers: [{ id: 'atreides', seats: [] }] },
{ version: 1, controllers: [{ id: 'atreides', seats: ['atreides'], secret: 'x' }] },
{ version: 2, controllers: [] },
{ version: 1, controllers: [], extra: true },
]) assert.throws(() => lobby(value, seats), TwoPlayerControllersError);
const state = assembled(), snapshot = structuredClone(state);
assert.throws(() => lobby(state, seats.slice(0, 3)), TwoPlayerControllersError);
assert.throws(() => lobby(state, ['atreides', 'atreides']), TwoPlayerControllersError);
assert.deepEqual(state, snapshot);
});
void test('incremental additions cannot steal, duplicate or exceed a controller seat', () => {
let state = add(create(), seats, 'atreides', 'atreides');
const before = structuredClone(state);
assert.throws(() => add(state, seats, 'atreides', 'atreides'), TwoPlayerControllersError);
assert.throws(() => add(state, seats, 'harkonnen', 'guild'), TwoPlayerControllersError);
assert.throws(() => add(state, seats, 'atreides', 'unknown'), TwoPlayerControllersError);
assert.deepEqual(state, before);
state = add(state, seats, 'atreides', 'fremen');
assert.throws(() => add(state, seats, 'atreides', 'guild'), TwoPlayerControllersError);
state = add(state, seats, 'harkonnen', 'harkonnen');
assert.throws(() => add(state, seats, 'guild', 'guild'), TwoPlayerControllersError);
});
void test('a partial or malformed mapping cannot alter victory calculations', () => {
const partial = add(create(), seats, 'atreides', 'atreides');
assert.throws(() => count(seats, partial), TwoPlayerControllersError);
assert.throws(() => target(seats, partial, false), TwoPlayerControllersError);
assert.throws(() => target(seats, partial, true), TwoPlayerControllersError);
assert.throws(() => count([], undefined), TwoPlayerControllersError);
assert.throws(() => count(['a', 'a'], undefined), TwoPlayerControllersError);
const restored = JSON.parse(JSON.stringify(assembled())) as TwoPlayerControllers;
complete(restored, seats);
assert.equal(target(seats, restored, false), 4);
});
+53
View File
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { GameView } from '../game/engine';
import { TwoPlayerControls } from '../components/two-player-controls';
const base = {
code: 'TWOPLAY2', status: 'lobby', host: 'a', me: 'a', expansions: [],
players: [
{ id: 'a', name: 'Ali', faction: 'atreides', ready: true },
{ id: 'b', name: 'Bea', faction: 'harkonnen', ready: false },
{ id: 'c', name: 'Ali', faction: 'emperor', ready: false },
],
twoPlayerControllers: { version: 1, controllers: [
{ id: 'one', seats: ['a', 'c'] }, { id: 'two', seats: ['b'] },
] },
controlledFactionSeats: [
{ id: 'a', name: 'Ali', faction: 'atreides' },
{ id: 'c', name: 'Ali', faction: 'emperor' },
],
} as unknown as GameView;
const noop = () => {};
function render(game: GameView, pendingEntry = false) {
return renderToStaticMarkup(createElement(TwoPlayerControls, {
game, disabled: pendingEntry, pendingEntry, entryRoom: game.code, entryProblem: '',
retryDisabled: false, onModeChange: noop, onSwitch: noop, onAdd: noop, onRetry: noop,
}));
}
void test('faction selector only offers the authenticated controller seats', () => {
const html = render(base);
assert.match(html, /Active faction/);
assert.match(html, /value="a"/);
assert.match(html, /value="c"/);
assert.doesNotMatch(html, /<option[^>]*value="b"/);
assert.match(html, /Player 2: Harkonnen \(not ready\) — needs a second faction/);
});
void test('second faction choices omit taken factions and disabled expansions', () => {
const html = render({ ...base, me: 'b', controlledFactionSeats: [{ id: 'b', name: 'Bea', faction: 'harkonnen' }] });
assert.match(html, /Add your second faction/);
assert.match(html, /value="fremen"/);
assert.doesNotMatch(html, /<option[^>]*value="atreides"/);
assert.doesNotMatch(html, /<option[^>]*value="ixians"/);
});
void test('an unresolved request stays visible with an explicit retry', () => {
const html = render(base, true);
assert.match(html, /Retry saved second-faction request/);
assert.match(html, /never retried automatically/);
assert.match(html, /<select[^>]*disabled/);
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, joinControlledFaction, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
function lobby(): Game {
let game = createGame('TWOPEOPLE', newPlayer('a', 'A', 'atreides'));
game = applyAction(game, 'a', { type: 'twoPlayerMode', enabled: true });
joinControlledFaction(game, newPlayer('b', 'A second', 'emperor'), 'a');
joinGame(game, newPlayer('c', 'C', 'guild'));
joinControlledFaction(game, newPlayer('d', 'C second', 'harkonnen'), 'c');
return game;
}
void test('two people can independently ready their two physical factions and finish setup', () => {
let game = lobby();
assert.deepEqual(game.twoPlayerControllers, { version: 1, controllers: [
{ id: 'a', seats: ['a', 'b'] }, { id: 'c', seats: ['c', 'd'] },
] });
assert.deepEqual(viewGame(game, 'b').controlledFactionSeats.map(seat => seat.id), ['a', 'b']);
assert.deepEqual(viewGame(game, 'd').controlledFactionSeats.map(seat => seat.id), ['c', 'd']);
for (const id of ['a', 'b', 'c', 'd']) game = applyAction(game, id, { type: 'ready' });
game = applyAction(game, 'a', { type: 'start' });
assert.equal(game.status, 'setup');
for (const id of ['a', 'b', 'c', 'd']) {
const privateView = viewGame(game, id);
for (const seat of privateView.players) {
assert.equal('traitorChoices' in seat, seat.id === id);
assert.equal('hand' in seat, seat.id === id);
}
}
// Harkonnen automatically keeps all four dealt traitors; the other factions
// each choose one before play begins.
assert.equal(game.players.find(player => player.id === 'd')!.traitors.length, 4);
assert.equal(game.players.find(player => player.id === 'd')!.traitorChoices.length, 0);
for (const id of ['a', 'b', 'c']) {
const player = game.players.find(seat => seat.id === id)!;
game = applyAction(game, id, { type: 'traitor', leader: player.traitorChoices[0] });
}
assert.equal(game.status, 'playing');
assert.equal(game.players.every(player => player.ally === null), true);
if (game.phaseOpening) {
for (const id of ['a', 'b', 'c', 'd']) game = applyAction(game, id, { type: 'ready' });
}
const dialers = [...game.stormDialers];
for (const id of dialers) game = applyAction(game, id, { type: 'stormDial', amount: 1 });
assert.equal(game.stormPending, 2);
});
void test('lobby preserves separate controller seats and refuses incomplete, excess and AI setup', () => {
let game = createGame('TWOPEOPLE', newPlayer('a', 'A', 'atreides'));
game = applyAction(game, 'a', { type: 'twoPlayerMode', enabled: true });
assert.throws(() => joinControlledFaction(game, newPlayer('x', 'X', 'emperor'), 'other'), /controller/);
joinControlledFaction(game, newPlayer('b', 'B', 'emperor'), 'a');
assert.throws(() => joinControlledFaction(game, newPlayer('x', 'X', 'guild'), 'b'), /two faction/);
assert.throws(() => applyAction(game, 'a', { type: 'twoPlayerMode', enabled: false }), /cannot be disabled/);
assert.throws(() => applyAction(game, 'a', { type: 'addBot', faction: 'guild', difficulty: 'Easy' }), /human/);
game = applyAction(game, 'a', { type: 'ready' });
game = applyAction(game, 'b', { type: 'ready' });
assert.throws(() => applyAction(game, 'a', { type: 'start' }), /exactly two controllers/);
assert.equal(game.players.length, 2);
});
void test('forged or missing controller assignments reject without mutating the game', () => {
const game = lobby();
game.twoPlayerControllers!.controllers[1].seats[1] = 'b';
const before = structuredClone(game);
assert.throws(() => viewGame(game, 'a'), /only one controller/);
assert.throws(() => applyAction(game, 'a', { type: 'ready' }), /only one controller/);
assert.deepEqual(game, before);
});
+124
View File
@@ -0,0 +1,124 @@
// @dune-suite integration
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
const receipt = () => ({ operationId: randomUUID(), sessionToken: randomBytes(32).toString('hex') });
type Wire = { code: string; me: string; version: number; players: { id: string; ready: boolean; hand?: unknown[] }[]; error?: string; entryReceipt?: { operationId: string; replayed: boolean } };
async function request(path: string, body?: unknown, cookie?: string, selected?: string) {
const res = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(cookie ? { cookie } : {}),
...(selected ? { 'X-Dune-Seat': selected } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: res.status, data: await res.json() as Wire, cookie: res.headers.get('set-cookie')?.split(';')[0] };
}
void test('two-person controlled seat creation, retry and selected credentials remain isolated', async () => {
const host = await request('/api/rooms', { name: 'Two person host', faction: 'atreides', advanced: false, expansions: [], entry: receipt() });
assert.equal(host.status, 201, JSON.stringify(host.data));
assert.ok(host.cookie);
const path = `/api/rooms/${host.data.code}`;
const enabled = await request(path, { version: host.data.version, action: { type: 'twoPlayerMode', enabled: true } }, host.cookie, host.data.me);
assert.equal(enabled.status, 200, JSON.stringify(enabled.data));
const attempt = { type: 'addControlledFaction', version: enabled.data.version, name: 'Host second faction', faction: 'harkonnen', entry: receipt() };
const added = await request(path, attempt, host.cookie, host.data.me);
assert.equal(added.status, 200, JSON.stringify(added.data));
assert.ok(added.cookie?.startsWith(`dune_${host.data.code}_${added.data.me}=`));
assert.notEqual(added.data.me, host.data.me);
assert.equal(added.data.players.length, 2);
assert.deepEqual(added.data.entryReceipt, { operationId: attempt.entry.operationId, replayed: false });
assert.ok(!JSON.stringify(added.data).includes(attempt.entry.sessionToken));
const both = `${host.cookie}; ${added.cookie}`;
const retry = await request(path, attempt, both, host.data.me);
assert.equal(retry.status, 200, JSON.stringify(retry.data));
assert.equal(retry.cookie, added.cookie);
assert.equal(retry.data.me, added.data.me);
assert.deepEqual(retry.data.entryReceipt, { operationId: attempt.entry.operationId, replayed: true });
const altered = await request(path, { ...attempt, version: attempt.version + 1 }, both, host.data.me);
assert.equal(altered.status, 409);
assert.equal(altered.cookie, undefined);
for (const id of [host.data.me, added.data.me]) {
const view = await request(path, undefined, both, id);
assert.equal(view.status, 200, JSON.stringify(view.data));
assert.equal(view.data.me, id);
const talk = await request(`${path}/messages`, undefined, both, id);
assert.equal(talk.status, 200, JSON.stringify(talk.data));
}
const missing = await request(path, undefined, host.cookie, added.data.me);
assert.equal(missing.status, 409);
const forged = await request(path, undefined, both, randomUUID());
assert.equal(forged.status, 409);
const defaultSeat = await request(path, undefined, both);
assert.equal(defaultSeat.data.me, host.data.me);
const stranger = await request(path, { type: 'join', name: 'Other person', faction: 'emperor', entry: receipt() });
assert.equal(stranger.status, 200, JSON.stringify(stranger.data));
assert.ok(stranger.cookie);
const stolenReceipt = await request(path, attempt, stranger.cookie, stranger.data.me);
assert.equal(stolenReceipt.status, 409);
assert.equal(stolenReceipt.cookie, undefined);
const wrongSeat = await request(`${path}/control`, { type: 'setRecoveryKey', version: stranger.data.version, recoverySecret: randomBytes(32).toString('hex') }, host.cookie, added.data.me);
assert.equal(wrongSeat.status, 409);
const secondAttempt = { type: 'addControlledFaction', version: stranger.data.version, name: 'Other second faction', faction: 'fremen', entry: receipt() };
const second = await request(path, secondAttempt, stranger.cookie, stranger.data.me);
assert.equal(second.status, 200, JSON.stringify(second.data));
assert.ok(second.cookie?.startsWith(`dune_${host.data.code}_${second.data.me}=`));
const otherCookies = `${stranger.cookie}; ${second.cookie}`;
const seats = [
{ id: host.data.me, cookie: both },
{ id: added.data.me, cookie: both },
{ id: stranger.data.me, cookie: otherCookies },
{ id: second.data.me, cookie: otherCookies },
];
const crossController = await request(path, undefined, both, second.data.me);
assert.equal(crossController.status, 409);
const reverseCrossController = await request(path, undefined, otherCookies, added.data.me);
assert.equal(reverseCrossController.status, 409);
let latest = second.data;
for (const seat of seats) {
const view = await request(path, undefined, seat.cookie, seat.id);
assert.equal(view.status, 200, JSON.stringify(view.data));
assert.equal(view.data.me, seat.id);
for (const player of view.data.players)
assert.equal(Object.hasOwn(player, 'hand'), player.id === seat.id);
const ready = await request(path, { version: latest.version, action: { type: 'ready' } }, seat.cookie, seat.id);
assert.equal(ready.status, 200, JSON.stringify(ready.data));
assert.equal(ready.data.me, seat.id);
latest = ready.data;
}
});
void test('recovering a scoped seat replaces its stale cookie without requiring a selector', async () => {
const host = await request('/api/rooms', { name: 'Recovery host', faction: 'atreides', advanced: false, expansions: [], entry: receipt() });
assert.equal(host.status, 201, JSON.stringify(host.data));
assert.ok(host.cookie);
const path = `/api/rooms/${host.data.code}`;
const enabled = await request(path, { version: host.data.version, action: { type: 'twoPlayerMode', enabled: true } }, host.cookie, host.data.me);
assert.equal(enabled.status, 200, JSON.stringify(enabled.data));
const added = await request(path, { type: 'addControlledFaction', version: enabled.data.version, name: 'Recovery second', faction: 'harkonnen', entry: receipt() }, host.cookie, host.data.me);
assert.equal(added.status, 200, JSON.stringify(added.data));
assert.ok(added.cookie);
const recoverySecret = randomBytes(32).toString('hex');
const setup = await request(`${path}/control`, { type: 'setRecoveryKey', version: added.data.version, recoverySecret }, added.cookie, added.data.me);
assert.equal(setup.status, 200, JSON.stringify(setup.data));
// Another browser recovers first, making the original scoped cookie stale.
const initial = await request(`${path}/control`, { type: 'recoverSeat', playerId: added.data.me, recoverySecret, operationId: randomUUID(), newSessionToken: randomBytes(32).toString('hex') });
assert.equal(initial.status, 200, JSON.stringify(initial.data));
const stale = await request(path, undefined, added.cookie, added.data.me);
assert.equal(stale.status, 409);
const attempt = { type: 'recoverSeat', playerId: added.data.me, recoverySecret, operationId: randomUUID(), newSessionToken: randomBytes(32).toString('hex') };
const recovered = await request(`${path}/control`, attempt, added.cookie);
assert.equal(recovered.status, 200, JSON.stringify(recovered.data));
assert.ok(recovered.cookie?.startsWith(`dune_${host.data.code}_${added.data.me}=`));
const view = await request(path, undefined, recovered.cookie, added.data.me);
assert.equal(view.status, 200, JSON.stringify(view.data));
assert.equal(view.data.me, added.data.me);
const retry = await request(`${path}/control`, attempt, added.cookie);
assert.equal(retry.status, 200, JSON.stringify(retry.data));
assert.equal(retry.cookie, recovered.cookie);
});
+20 -1
View File
@@ -8,7 +8,8 @@ import type { BoardContext, BoardSeat } from '../game/board-resolution-quote';
import type { Game } from '../game/engine';
import { createTechTokens, TECH_TOKENS } from '../game/tech-tokens';
import { MOBILE_STRONGHOLD, MOBILE_LOCATION } from '../game/board';
type Context = BoardContext & Pick<Game, 'techTokens'>;
import type { TwoPlayerControllers } from '../game/two-player-controllers';
type Context = BoardContext & Pick<Game, 'techTokens'> & { twoPlayerControllers?: TwoPlayerControllers };
const keys = [
'arrakeen:10',
'carthag:11',
@@ -94,6 +95,24 @@ void test('unallied target is three except in a two-player game', () => {
occupy(g, 'e', keys);
assert.equal(row(g).qualifies, true);
});
void test('two controllers with four factions need four holds without pooling their separate factions', () => {
const g = fixture(false);
g.players = [...g.players, { id: 'h', faction: 'harkonnen', ally: null, forces: {} }];
g.order = [...g.order, 'h'];
g.twoPlayerControllers = { version: 1, controllers: [
{ id: 'e', seats: ['e', 'a'] }, { id: 'x', seats: ['x', 'h'] },
] };
occupy(g, 'e', keys.slice(0, 3));
occupy(g, 'a', [keys[3]]);
assert.equal(row(g).target, 4);
assert.deepEqual(row(g).members, ['e']);
assert.equal(row(g).qualifies, false);
occupy(g, 'a', []);
occupy(g, 'e', keys);
assert.equal(row(g).qualifies, true);
g.twoPlayerControllers.controllers[1].seats.pop();
assert.throws(() => strongholdProgress(g), VictoryProgressError);
});
void test('technology requires one member to hold all three and never supplies a joint territory', () => {
const g = fixture();
occupy(g, 'e', keys.slice(0, 2));
+12
View File
@@ -97,6 +97,18 @@ void test('two seats require four normal holds', () => {
g.players[0].forces['tueks_sietch:5'] = 1;
compare(g, ['p0']);
});
void test('two people controlling four factions require four holds for a single faction', () => {
const g = fixture(['atreides', 'emperor', 'beneGesserit', 'harkonnen'], false);
Object.assign(g, { twoPlayerControllers: { version: 1, controllers: [
{ id: 'p0', seats: ['p0', 'p1'] }, { id: 'p2', seats: ['p2', 'p3'] },
] } });
occupy(g, 0, 'arrakeen', 'carthag', 'sietch_tabr');
occupy(g, 1, 'tueks_sietch');
compare(g, []);
occupy(g, 1);
g.players[0].forces['tueks_sietch:5'] = 1;
compare(g, ['p0']);
});
void test('allies combine sole team occupancy at four, including a shared allied stronghold', () => {
const g = fixture();
g.players[0].ally = 'p1';