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

+9
View File
@@ -14,6 +14,15 @@ npm run dev -- --host 0.0.0.0
Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels.
For the provisional two-person setup, the host enables **Two players, two
factions each** in the lobby. Each person joins with one faction, then adds a
second and uses **Active faction** to switch seats. Ready each faction before
the host starts. The factions are independent unless they form an alliance;
the ordinary victory target is four strongholds per side. The prototype
requires human-controlled seats and cannot be disabled after a second faction
is added. Counts for optional modules and wider interactions remain provisional.
See [the implementation scope](docs/TWO_PLAYER_TWO_FACTIONS.md).
New ordinary-auction lots show a **ten-second bidding countdown**. Native
inspection and response decisions hold the clock. At expiry, the server passes
or submits only a bid required by a binding Truthtrance answer; normal payment
+38 -20
View File
@@ -1,6 +1,6 @@
import { requestOrigin } from '@/lib/request-origin';
import { authenticateSelectedSeat, selectedSeatId, seatCookie, roomSeatCookies } from '@/lib/selected-seat';
import {
authenticate,
createSeatHandover,
revokeSeatHandover,
claimSeatHandover,
@@ -49,13 +49,7 @@ export async function POST(req: Request) {
400,
);
const input = body as Record<string, unknown>;
const token =
req.headers
.get('cookie')
?.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`dune_${code}=`))
?.split('=')[1] ?? '';
const selector = selectedSeatId(req);
if (
input.type === 'setSeatAiDelegate' ||
input.type === 'revokeSeatAiDelegate' ||
@@ -67,7 +61,7 @@ export async function POST(req: Request) {
'INVALID_CONTROL_REQUEST',
400,
);
const auth = await authenticate(code, token);
const auth = await authenticateSelectedSeat(req, code);
const result =
input.type === 'setSeatAiDelegate'
? await setSeatAiDelegate(code, auth, input.version, {
@@ -95,7 +89,7 @@ export async function POST(req: Request) {
input.type === 'createSeatHandover' ||
input.type === 'revokeSeatHandover'
) {
const auth = await authenticate(code, token);
const auth = await authenticateSelectedSeat(req, code);
if (input.type === 'createSeatHandover')
return Response.json(
await createSeatHandover(code, auth, input.version, {
@@ -120,20 +114,44 @@ export async function POST(req: Request) {
'INVALID_CONTROL_REQUEST',
400,
);
if (input.type === 'claimSeatHandover' && token) {
let existing;
if (typeof input.playerId !== 'string' || !input.playerId || input.playerId.length > 100)
throw new SeatControlError('Provide a valid player ID.', 'INVALID_CONTROL_REQUEST', 400);
const targetId = input.playerId;
if (input.type === 'claimSeatHandover') {
// A caller cannot evade the existing-browser restriction by selecting a
// nonexistent scoped cookie while retaining another legacy credential.
for (const cookie of roomSeatCookies(req, code)) {
if (!cookie) continue;
let existing;
try {
existing = await authenticateSelectedSeat(new Request(req.url, { headers: { cookie: `dune_${code}=${cookie}` } }), code);
} catch (error) {
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
}
if (existing && existing.playerId !== targetId)
throw new SeatControlError(
'This browser already controls another seat in this room. Use a separate browser profile to accept the handover.',
'ALREADY_SEATED', 409,
);
}
}
if (selector && selector !== targetId)
throw new SeatControlError('The selected seat does not match this recovery request.', 'INVALID_CONTROL_REQUEST', 400);
// Determine ownership before recovery rotates the old credential.
const existingLegacy = seatCookie(req, `dune_${code}`);
let legacyOtherSeat = false;
if (existingLegacy) {
try {
existing = await authenticate(code, token);
legacyOtherSeat = (await authenticateSelectedSeat(new Request(req.url, { headers: { cookie: `dune_${code}=${existingLegacy}` } }), code)).playerId !== targetId;
} catch (error) {
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
}
if (existing && existing.playerId !== input.playerId)
throw new SeatControlError(
'This browser already controls another seat in this room. Use a separate browser profile to accept the handover.',
'ALREADY_SEATED',
409,
);
}
// A recovery claim may omit the selector. Replace any existing scoped
// cookie for its target so that a stale token cannot shadow the new one.
const hasTargetCookie = !!seatCookie(req, `dune_${code}_${targetId}`);
const cookieName = hasTargetCookie || legacyOtherSeat
? `dune_${code}_${targetId}` : `dune_${code}`;
const result =
input.type === 'claimSeatHandover'
? await claimSeatHandover(code, {
@@ -160,7 +178,7 @@ export async function POST(req: Request) {
{
headers: {
...noStore,
'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
'Set-Cookie': `${cookieName}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
},
},
);
+3 -9
View File
@@ -1,6 +1,7 @@
import { env } from 'cloudflare:workers';
import { requestOrigin } from '@/lib/request-origin';
import { authenticate, RoomEntryError } from '@/db/rooms';
import { RoomEntryError } from '@/db/rooms';
import { authenticateSelectedSeat } from '@/lib/selected-seat';
import { readTableTalk, sendTableTalk, TableTalkError } from '@/db/table-talk';
import { RuleError } from '@/game/engine';
const noStore = { 'Cache-Control': 'no-store' };
@@ -16,14 +17,7 @@ async function handle(req: Request, write: boolean) {
const code = url.pathname.split('/').at(-2)!.toUpperCase();
if (!/^[A-Z2-9]{8}$/.test(code))
throw new TableTalkError('Invalid room code.', 400);
const token =
req.headers
.get('cookie')
?.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`dune_${code}=`))
?.split('=')[1] ?? '';
const auth = await authenticate(code, token);
const auth = await authenticateSelectedSeat(req, code);
if (write) {
const raw = await req.text();
if (raw.length > 6500)
+19 -13
View File
@@ -1,8 +1,9 @@
import { requestOrigin } from '@/lib/request-origin';
import { authenticateSelectedSeat, selectedSeatId, seatCookie } from '@/lib/selected-seat';
import {
readSeatView,
authenticate,
joinRoom,
addControlledFaction,
act,
RoomEntryError,
needsAutomaticRoomRecovery,
@@ -19,13 +20,6 @@ const codeOf = (req: Request) => {
throw new RuleError('Enter the eight-character room code.');
return code;
};
const tokenOf = (req: Request, code: string) =>
req.headers
.get('cookie')
?.split(';')
.map((x) => x.trim())
.find((x) => x.startsWith(`dune_${code}=`))
?.split('=')[1] ?? '';
const failure = (e: unknown) =>
Response.json(
{
@@ -52,7 +46,7 @@ const failure = (e: unknown) =>
export async function GET(req: Request) {
try {
const code = codeOf(req),
id = await authenticate(code, tokenOf(req, code));
id = await authenticateSelectedSeat(req, code);
const view = await readSeatView(code, id);
// Reconnect resumes persisted automatic effects/acknowledgements and AI work
// after an interruption. Actual human choices remain engine-owned decisions.
@@ -83,13 +77,13 @@ export async function POST(req: Request) {
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new RuleError('The request is invalid.');
if (body.type === 'join') {
const existing = tokenOf(req, code);
const existing = selectedSeatId(req) || seatCookie(req, `dune_${code}`);
let auth: SeatAuth | null = null;
if (existing) {
try {
auth = await authenticate(code, existing);
auth = await authenticateSelectedSeat(req, code);
} catch (error) {
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
if (selectedSeatId(req) || error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
}
}
if (body.entry === undefined && auth) {
@@ -124,7 +118,19 @@ export async function POST(req: Request) {
},
);
}
const id = await authenticate(code, tokenOf(req, code));
if (body.type === 'addControlledFaction') {
const auth = await authenticateSelectedSeat(req, code);
if (!Number.isSafeInteger(body.version) || typeof body.name !== 'string' || typeof body.faction !== 'string')
throw new RuleError('Enter your name and faction and provide the current table version.');
const r = await addControlledFaction(code, auth, body.version, body.name, body.faction as FactionId, body.entry);
return Response.json({ ...r.view, ...(r.entryReceipt ? { entryReceipt: r.entryReceipt } : {}) }, {
headers: {
'Set-Cookie': `dune_${code}_${r.view.me}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
'Cache-Control': 'no-store',
},
});
}
const id = await authenticateSelectedSeat(req, code);
if (
!Number.isSafeInteger(body.version) ||
!body.action ||
+150 -10
View File
@@ -6,6 +6,8 @@ import { GameTable } from '@/components/game-table';
import { SeatConfirmation, type SeatConfirmationKind } from '@/components/seat-confirmation';
import { RoomEntryConfirmation } from '@/components/room-entry-confirmation';
import { RulesetControls } from '@/components/ruleset-controls';
import { TwoPlayerControls } from '@/components/two-player-controls';
import { randomId } from '@/lib/random-id';
import {
SeatHandoverSetup,
SeatHandoverClaim,
@@ -55,6 +57,9 @@ const inviteRejected = (error: unknown) =>
error instanceof ClientRequestError &&
error.kind === 'http' &&
[401, 403, 404, 409].includes(error.status ?? 0);
type TwoPlayerEntry = { room: string; seat: string; bodyText: string };
const TWO_PLAYER_ENTRY_KEY = 'dune.pending-controlled-faction.v1';
export default function Home() {
const activeRoom = useRef<string | null>(null);
const activeSeat = useRef<string | null>(null);
@@ -96,6 +101,8 @@ export default function Home() {
const [handoverPending, setHandoverPending] = useState(true);
const [aiPermissionPending, setAiPermissionPending] = useState(true);
const [abandonAcknowledged, setAbandonAcknowledged] = useState(false);
const [twoPlayerEntry, setTwoPlayerEntry] = useState<TwoPlayerEntry | null>(null);
const [twoPlayerEntryProblem, setTwoPlayerEntryProblem] = useState('');
const f = faction(selected);
const roomCode = game?.code;
const roomUnavailable = !!game && removedRoom === game.code;
@@ -110,20 +117,25 @@ export default function Home() {
// This function is deliberately GET-only, including WebMCP reads and recovery.
const refresh = useCallback(
async (room: string, fresh = false): Promise<GameView> => {
let pending = reads.current.get(room);
const seat = activeSeat.current;
const readKey = `${room}:${seat ?? ''}`;
let pending = reads.current.get(readKey);
if (pending && !fresh) return pending;
// Reconciliation must start after previously issued reads have settled.
while (pending) {
await pending.catch(() => {});
pending = reads.current.get(room);
pending = reads.current.get(readKey);
}
const readEpoch = epoch.current;
const request = requestJson<GameView>(`/api/rooms/${room}`, {
cache: 'no-store',
headers: seat ? { 'X-Dune-Seat': seat } : undefined,
})
.then((data) => {
if (
activeRoom.current !== room ||
activeSeat.current !== seat ||
(seat !== null && data.me !== seat) ||
readEpoch !== epoch.current ||
(removedRooms.current.has(room) && !fresh) ||
data.version < (knownVersions.current.get(room) ?? -1)
@@ -163,9 +175,9 @@ export default function Home() {
return data;
})
.finally(() => {
reads.current.delete(room);
reads.current.delete(readKey);
});
reads.current.set(room, request);
reads.current.set(readKey, request);
return request;
},
[],
@@ -216,16 +228,17 @@ export default function Home() {
try {
const next = await requestJson<GameView>(`/api/rooms/${room}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Dune-Seat': seat! },
body: JSON.stringify({
version: data.version,
action: { type: 'advanceBots' },
}),
});
if (next.me !== seat) throw new ClientRequestError('The server returned a different faction seat.', 'invalid-response');
if (
activeRoom.current === room &&
activeSeat.current === seat &&
epoch.current === requestEpoch
epoch.current === requestEpoch && next.me === seat
) {
knownVersions.current.set(
room,
@@ -265,6 +278,22 @@ export default function Home() {
);
useEffect(() => {
try {
const saved = window.sessionStorage.getItem(TWO_PLAYER_ENTRY_KEY);
if (saved) {
const value: unknown = JSON.parse(saved);
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid saved request');
const attempt = value as TwoPlayerEntry;
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
if (Object.keys(attempt).sort().join(',') !== 'bodyText,room,seat' || typeof attempt.room !== 'string' || !/^[A-Z2-9]{8}$/.test(attempt.room) || typeof attempt.seat !== 'string' || !uuid.test(attempt.seat) || typeof attempt.bodyText !== 'string' || attempt.bodyText.length > 4096) throw new Error('Invalid saved request');
const body = JSON.parse(attempt.bodyText);
if (!body || typeof body !== 'object' || Array.isArray(body) || JSON.stringify(body) !== attempt.bodyText || Object.keys(body).sort().join(',') !== 'entry,faction,name,type,version' || body.type !== 'addControlledFaction' || typeof body.version !== 'number' || !Number.isSafeInteger(body.version) || body.version < 0 || typeof body.name !== 'string' || !body.name.trim() || body.name.length > 32 || !FACTIONS.some((f) => f.id === body.faction) || !body.entry || typeof body.entry !== 'object' || Object.keys(body.entry).sort().join(',') !== 'operationId,sessionToken' || typeof body.entry.operationId !== 'string' || !uuid.test(body.entry.operationId) || typeof body.entry.sessionToken !== 'string' || !/^[0-9a-f]{64}$/.test(body.entry.sessionToken)) throw new Error('Invalid saved request');
// oxlint-disable-next-line react/react-compiler -- Restore a private retry record from this tab.
setTwoPlayerEntry(attempt);
}
} catch {
setTwoPlayerEntryProblem('The saved second-faction request could not be read safely. It remains in this tab; restore access to its storage before continuing.');
}
// A one-use handover needs its exact receipt before any other seat flow.
try {
if (window.sessionStorage.getItem(HANDOVER_CLAIM_STORAGE_KEY) !== null) {
@@ -533,7 +562,7 @@ export default function Home() {
}
async function send(action: Action) {
if (!game || removedRooms.current.has(game.code) || mutationPending.current || recovery.current) return;
if (!game || removedRooms.current.has(game.code) || mutationPending.current || recovery.current || twoPlayerEntry || twoPlayerEntryProblem) return;
if (game.roomControl?.closed) { setNotice('An administrator closed this room. The saved table remains readable; play resumes after reopening.'); return; }
if (game.roomControl?.paused && !(action.type === 'setAutopilot' && action.difficulty === null)) {
setNotice('An administrator paused this room. Game decisions will be available when the room resumes.');
@@ -548,13 +577,14 @@ export default function Home() {
try {
const data = await requestJson<GameView>(`/api/rooms/${room}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Dune-Seat': seat },
body: JSON.stringify({ version: game.version, action }),
});
if (data.me !== seat) throw new ClientRequestError('The server returned a different faction seat.', 'invalid-response');
if (
activeRoom.current === room &&
activeSeat.current === seat &&
epoch.current === requestEpoch
epoch.current === requestEpoch && data.me === seat
) {
knownVersions.current.set(
room,
@@ -588,6 +618,103 @@ export default function Home() {
}
}
function switchFaction(seat: string) {
if (!game || !game.controlledFactionSeats?.some((owned) => owned.id === seat) ||
seat === game.me || mutationPending.current || reconnectPending.current ||
recovery.current || handoverPending || aiPermissionPending || claimUncertain ||
twoPlayerEntry || twoPlayerEntryProblem || roomUnavailable) return;
const room = game.code;
++epoch.current;
activeSeat.current = seat;
setHandoverPending(true);
setAiPermissionPending(true);
setGame(null);
setRestoring(true);
setRestoreFailed(false);
setNotice('');
void refresh(room, true).catch((error: Error) => {
if (activeRoom.current === room && activeSeat.current === seat) {
setRestoreFailed(true);
setNotice(error.message);
}
}).finally(() => {
if (activeRoom.current === room && activeSeat.current === seat) setRestoring(false);
});
}
async function dispatchControlledFaction(attempt: TwoPlayerEntry, firstDispatch: boolean) {
if (mutationPending.current || reconnectPending.current || recovery.current || handoverPending || aiPermissionPending || claimUncertain || activeRoom.current !== attempt.room) return;
const requestEpoch = epoch.current;
mutationPending.current = true;
setBusy(true);
setNotice('');
let responseReceived = false;
try {
const data = await requestJson<GameView & { entryReceipt?: { operationId: string; replayed: boolean } }>(`/api/rooms/${attempt.room}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Dune-Seat': attempt.seat },
body: attempt.bodyText,
});
responseReceived = true;
if (activeRoom.current !== attempt.room || requestEpoch !== epoch.current) return;
const body = JSON.parse(attempt.bodyText);
if (data.code !== attempt.room || data.me === attempt.seat ||
data.entryReceipt?.operationId !== body.entry.operationId || typeof data.entryReceipt?.replayed !== 'boolean' ||
!data.players.some((player) => player.id === data.me && player.faction === body.faction))
throw new ClientRequestError('The server did not confirm the saved second-faction request.', 'invalid-response');
window.sessionStorage.removeItem(TWO_PLAYER_ENTRY_KEY);
if (window.sessionStorage.getItem(TWO_PLAYER_ENTRY_KEY) !== null) throw new Error('The saved retry proof could not be removed.');
setTwoPlayerEntry(null);
++epoch.current;
activeSeat.current = data.me;
knownVersions.current.set(attempt.room, Math.max(data.version, knownVersions.current.get(attempt.room) ?? -1));
setHandoverPending(true);
setAiPermissionPending(true);
setGame(data);
setNotice('Second faction added. You are now viewing its seat. Switch factions to make each faction ready.');
} catch (error) {
if (activeRoom.current !== attempt.room || requestEpoch !== epoch.current) return;
if (firstDispatch && !responseReceived && !requestMayHaveCompleted(error)) {
try {
window.sessionStorage.removeItem(TWO_PLAYER_ENTRY_KEY);
if (window.sessionStorage.getItem(TWO_PLAYER_ENTRY_KEY) !== null) throw new Error();
setTwoPlayerEntry(null);
} catch {
setTwoPlayerEntryProblem('The rejected request was not sent again, but its saved proof could not be removed. Restore storage access before continuing.');
}
}
setNotice(!firstDispatch || responseReceived || requestMayHaveCompleted(error)
? `${(error as Error).message} The request may have completed. Retry the saved request exactly; it will not be sent again automatically.`
: (error as Error).message);
} finally {
mutationPending.current = false;
setBusy(false);
}
}
async function addControlledFaction(name: string, factionId: string) {
if (!game || twoPlayerEntry || twoPlayerEntryProblem || mutationPending.current || recovery.current || handoverPending || aiPermissionPending || claimUncertain) return;
const attempt: TwoPlayerEntry = {
room: game.code,
seat: game.me,
bodyText: JSON.stringify({
type: 'addControlledFaction', version: game.version, name, faction: factionId,
entry: { operationId: randomId(), sessionToken: Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, '0')).join('') },
}),
};
try {
if (window.sessionStorage.getItem(TWO_PLAYER_ENTRY_KEY) !== null) throw new Error();
const text = JSON.stringify(attempt);
window.sessionStorage.setItem(TWO_PLAYER_ENTRY_KEY, text);
if (window.sessionStorage.getItem(TWO_PLAYER_ENTRY_KEY) !== text) throw new Error();
} catch {
setTwoPlayerEntryProblem('The second-faction request could not be saved safely. Nothing was sent; restore storage access before continuing.');
return;
}
setTwoPlayerEntry(attempt);
await dispatchControlledFaction(attempt, true);
}
async function reconnect(resumeBots = false) {
const room = activeRoom.current;
if (!room || mutationPending.current || reconnectPending.current) return;
@@ -725,10 +852,23 @@ export default function Home() {
{seatConfirmation && !roomUnavailable && <div className="table-shell">
<SeatConfirmation kind={seatConfirmation} onContinue={() => setSeatConfirmation(null)} />
</div>}
{!roomUnavailable && <div className="table-shell"><TwoPlayerControls
game={game}
disabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntry || !!twoPlayerEntryProblem}
pendingEntry={twoPlayerEntry !== null}
entryRoom={twoPlayerEntry?.room}
entryProblem={twoPlayerEntryProblem}
onModeChange={(enabled) => void send({ type: 'twoPlayerMode', enabled })}
onSwitch={switchFaction}
onAdd={(name, factionId) => void addControlledFaction(name, factionId)}
onRetry={() => { if (twoPlayerEntry) void dispatchControlledFaction(twoPlayerEntry, false); }}
retryDisabled={busy || needsReconcile || checkingConnection || handoverPending || aiPermissionPending || claimUncertain || !!twoPlayerEntryProblem || twoPlayerEntry?.room !== game.code}
/></div>}
{roomUnavailable ? <main className="table-shell"><RemovedRoomNotice code={game.code} busy={busy || checkingConnection} onRetry={() => void reconnect()} onExit={exitTable} exitDisabled={handoverPending || aiPermissionPending || claimUncertain} /></main> : <GameTable
key={`${game.code}:${game.me}`}
game={game}
send={send}
busy={busy || needsReconcile}
busy={busy || needsReconcile || !!twoPlayerEntry || !!twoPlayerEntryProblem}
onExit={exitTable}
exitDisabled={handoverPending || aiPermissionPending}
/>}
+1
View File
@@ -1602,6 +1602,7 @@ export function GameTable({
))}
{g.status === 'lobby' &&
g.host === me.id &&
!g.twoPlayerControllers &&
g.players.length < 6 &&
availableBotFactions.length > 0 && (
<section className="bot-controls" aria-label="Add an AI player">
+1 -1
View File
@@ -99,7 +99,7 @@ export function SeatAiDelegation({
`/api/rooms/${game.code}/control`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Dune-Seat': game.me },
body: JSON.stringify(request),
},
);
+1 -1
View File
@@ -112,7 +112,7 @@ export function SeatHandoverSetup({
revoked?: true;
}>(`/api/rooms/${game.code}/control`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Dune-Seat': game.me },
body: JSON.stringify(
operation === 'create'
? {
+1 -1
View File
@@ -73,7 +73,7 @@ export function SeatRecoverySetup({
`/api/rooms/${kit.roomCode}/control`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Dune-Seat': game.me },
body: JSON.stringify({
type: 'setRecoveryKey',
version: game.version,
+6 -3
View File
@@ -98,6 +98,7 @@ export function TableTalk({ game }: { game: GameView }) {
try {
const result = await requestJson<TalkPage>(url, {
signal: controller.signal,
headers: { 'X-Dune-Seat': game.me },
});
if (
!controller.signal.aborted &&
@@ -129,7 +130,7 @@ export function TableTalk({ game }: { game: GameView }) {
clearTimeout(timer);
channelGeneration.current = generation + 1;
};
}, [url, open, refresh]);
}, [url, open, refresh, game.me]);
async function older() {
if (!shownPage.before || olderPending.current) return;
@@ -139,7 +140,9 @@ export function TableTalk({ game }: { game: GameView }) {
setLoading(true);
const generation = channelGeneration.current;
try {
const result = await requestJson<TalkPage>(pageUrl(shownPage.before));
const result = await requestJson<TalkPage>(pageUrl(shownPage.before), {
headers: { 'X-Dune-Seat': game.me },
});
if (generation === channelGeneration.current) {
setMuted(result.muted === true);
setPage((current) => ({
@@ -175,7 +178,7 @@ export function TableTalk({ game }: { game: GameView }) {
endpoint,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Dune-Seat': game.me },
body: JSON.stringify(draft),
},
);
+84
View File
@@ -0,0 +1,84 @@
'use client';
import { useState } from 'react';
import { FACTIONS } from '@/game/catalog';
import type { GameView } from '@/game/engine';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export function TwoPlayerControls({
game, disabled, pendingEntry, entryRoom, entryProblem, retryDisabled,
onModeChange, onSwitch, onAdd, onRetry,
}: {
game: GameView;
disabled: boolean;
pendingEntry: boolean;
entryRoom?: string;
entryProblem: string;
retryDisabled: boolean;
onModeChange: (enabled: boolean) => void;
onSwitch: (seat: string) => void;
onAdd: (name: string, faction: string) => void;
onRetry: () => void;
}) {
const [name, setName] = useState(() => game.players.find((player) => player.id === game.me)?.name ?? '');
const [choice, setChoice] = useState('');
const mode = game.twoPlayerControllers;
const controlled = game.controlledFactionSeats ?? [];
const modeLocked = !!mode?.controllers.some((controller) => controller.seats.length > 1);
const available = FACTIONS.filter((item) =>
(item.expansion === 'base' || game.expansions.includes(item.expansion)) &&
!game.players.some((player) => player.faction === item.id),
);
if (game.status !== 'lobby' && !mode && !pendingEntry && !entryProblem) return null;
return (
<section className="notice" aria-label="Two-player faction controls">
{game.status === 'lobby' && game.host === game.me && (
<label className="checkbox-row">
<input type="checkbox" checked={!!mode} disabled={disabled || modeLocked} onChange={(event) => onModeChange(event.target.checked)} />
Two players, two factions each
</label>
)}
{game.status === 'lobby' && game.host === game.me && modeLocked && <p className="fine">This mode cannot be turned off after a second faction has been added.</p>}
{mode && <>
<h2>Two-player factions</h2>
<p>Each player controls two separate factions. They act independently and are not automatically allied; this interpretation is provisional. Switch between your faction seats to make their decisions and mark each one ready.</p>
{game.status === 'lobby' && <ul>
{mode.controllers.map((controller, index) => <li key={controller.id}>
Player {index + 1}: {controller.seats.map((id) => {
const player = game.players.find((item) => item.id === id);
return player ? `${FACTIONS.find((item) => item.id === player.faction)?.name ?? player.faction}${player.ready ? ' (ready)' : ' (not ready)'}` : 'unknown faction';
}).join(', ')}{controller.seats.length < 2 ? ' — needs a second faction' : ''}
</li>)}
</ul>}
{controlled.length > 1 && <label>
Active faction
<select value={game.me} disabled={disabled} onChange={(event) => onSwitch(event.target.value)}>
{controlled.map((seat) => <option key={seat.id} value={seat.id}>
{FACTIONS.find((item) => item.id === seat.faction)?.name ?? seat.faction} ({seat.name})
</option>)}
</select>
</label>}
{game.status === 'lobby' && controlled.length === 1 && <form onSubmit={(event) => {
event.preventDefault();
if (choice && name.trim() && available.some((item) => item.id === choice)) onAdd(name.trim(), choice);
}}>
<h3>Add your second faction</h3>
<label htmlFor="second-faction-seat-name">Seat name</label>
<Input id="second-faction-seat-name" value={name} maxLength={32} disabled={disabled} onChange={(event) => setName(event.target.value)} />
<label>Faction <select value={choice} disabled={disabled} onChange={(event) => setChoice(event.target.value)}>
<option value="">Choose a faction</option>
{available.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select></label>
<Button type="submit" disabled={disabled || !name.trim() || !available.some((item) => item.id === choice)}>Add second faction</Button>
</form>}
</>}
{pendingEntry && <div>
<output>A second-faction request for room {entryRoom} is saved in this tab. Retry the same request to confirm its result; it is never retried automatically.</output>
<Button variant="outline" disabled={retryDisabled} onClick={onRetry}>Retry saved second-faction request</Button>
{entryRoom !== game.code && <p>Open that room to retry this request.</p>}
</div>}
{entryProblem && <p role="alert">{entryProblem}</p>}
</section>
);
}
+60
View File
@@ -4,6 +4,7 @@ import {
normalizeAutomaticGame,
createGame,
joinGame,
joinControlledFaction,
newPlayer,
viewGame,
synchronizeAuctionClock,
@@ -446,6 +447,65 @@ export async function joinRoom(
throw error;
}
}
/** Add a separately authenticated faction under an existing two-person controller. */
export async function addControlledFaction(
code: string, auth: SeatAuth, version: number, name: string, faction: FactionId,
entryInput?: unknown,
): Promise<EntryResult & { token: string }> {
const id = crypto.randomUUID();
const player = newPlayer(id, name, faction);
const entry = await prepareEntry(entryInput, {
kind: 'controlled-faction', room: code, actor: auth.playerId, version,
name: player.name, faction,
});
// A receipt is not authority: revalidate the original controller on every replay.
async function checkedReplay() {
await readSeatView(code, auth);
const snapshot = await readRoomSnapshot(code);
const controller = snapshot.game.twoPlayerControllers?.controllers.find(c => c.seats.includes(auth.playerId));
if (!controller) throw new RuleError('Your seat is not a two-person controller.');
const replay = await replayEntry(entry);
if (replay && !controller.seats.includes(replay.view.me))
throw new RoomEntryError('This room-entry receipt does not belong to your controller.', 'ENTRY_RECEIPT_INVALID');
return replay;
}
try {
const replay = await checkedReplay();
if (replay) return replay;
await requireUnusedEntryToken(entry);
const { game: g, control } = await readRoomSnapshot(code);
if (control.closed) throw new RoomClosedError();
if (control.paused || control.joinLocked)
throw new RoomEntryError(
control.paused ? 'An administrator paused this room. New players may join after it resumes.' : 'New joins are locked by an administrator.',
control.paused ? 'ROOM_PAUSED' : 'ROOM_JOIN_LOCKED',
);
if (g.version !== version) throw new RoomEntryError('The table changed. Retry the same room-entry request.', 'ENTRY_CONFLICT');
joinControlledFaction(g, player, auth.playerId);
g.version++;
const token = entry?.sessionToken ?? Array.from(crypto.getRandomValues(new Uint8Array(32)), n => n.toString(16).padStart(2, '0')).join('');
const tokenHash = entry?.sessionHash ?? await hash(token);
const results = await db().batch([
db().prepare('UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND (paused = 1 OR join_locked = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = rooms.code AND player_id = ? AND token_hash = ? AND revoked = 0)')
.bind(JSON.stringify(g), Date.now(), code, version, auth.playerId, auth.tokenHash),
db().prepare('INSERT INTO seats(token_hash,room_code,player_id) SELECT ?,?,? WHERE changes() = 1').bind(tokenHash, code, id),
...(entry ? [db().prepare('INSERT INTO room_entry_receipts(operation_hash,request_hash,session_hash,room_code,player_id) SELECT ?,?,?,?,? WHERE changes() = 1')
.bind(entry.operationHash, entry.requestHash, entry.sessionHash, code, id)] : []),
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
throw new RoomEntryError('The table or your seat changed. Retry the same room-entry request.', 'ENTRY_CONFLICT');
}
return { token, view: await readSeatView(code, { playerId: id, tokenHash }),
...(entry ? { entryReceipt: { operationId: entry.operationId, replayed: false } } : {}) };
} catch (error) {
const replay = await checkedReplay();
if (replay) return replay;
await requireUnusedEntryToken(entry);
throw error;
}
}
export async function act(
code: string,
auth: SeatAuth,
+14 -3
View File
@@ -16,6 +16,18 @@ open mode gates.
## Current checkpoint and work
**Two people, four factions — 27 October. Prototyped / Partial.** Each person
can add a separately authenticated second faction and switch its private view
and actions. Exactly two controllers with two seats each are required to start;
ordinary victory uses the four-stronghold target. Independent factions and no
automatic alliance are provisional. Focused setup, victory, UI and isolated
HTTP cases pass; complete games, player-count interactions, comprehensive
custody review and deployed acceptance remain open. Human seats are required;
handover into a browser holding another active room seat remains unsupported.
[A dedicated isolated browser room](TWO_PLAYER_TWO_FACTIONS.md) also exercised
adding, switching and independently readying the host's two factions.
[Scope](TWO_PLAYER_TWO_FACTIONS.md).
**Next: remaining rule functions and native timing.**
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
@@ -25,9 +37,8 @@ clean boundary the claiming player may provisionally pause its own started
Charity opportunity and resume last, keeping the original claim and payment.
The related nine-file union, including the repaired v3 Revival controls,
passes 51 cases and the production build passes; broader combinations and
deployed acceptance remain open. The optional
two-human/four-faction setup is separately documented as missing in
[its implementation contract](TWO_PLAYER_TWO_FACTIONS.md).
deployed acceptance remain open. The separate optional two-human/four-faction
setup has since been prototyped above.
**Agreed extra turns and Basic simultaneous victory — 27 October. Prototyped /
Partial.** The public continuation now covers Basic qualifiers and tied
+13
View File
@@ -1,5 +1,18 @@
# Dune implementation status
## 27 October 2026 — Two people, four factions
The provisional setup maps two controllers to four independent faction seats,
with separate credentials, private views and actions, and a shared four-hold
victory target. Fifty-nine focused offline cases and two isolated HTTP cases
passed, including setup and initial storm decisions, selected-seat actions and
scoped-seat recovery. The independent-faction interpretation, player-count
interactions and broader acceptance remain provisional. Human seats are
required; handover to a browser already holding a different active room seat is
not supported. An isolated browser room additionally exercised adding, switching
and independently readying the host's two factions; it did not cover a second
human or a complete game. [Scope](TWO_PLAYER_TWO_FACTIONS.md).
## 27 October 2026 — Advanced Sapho Charity suspension
A player may provisionally pause its own started Charity turn at a clean
+9
View File
@@ -1,5 +1,14 @@
# Rule decision index
## Two people, two factions each — 27 October 2026
The classic rulebook and the supplied Advanced revision permit two people to
play two factions each and use a four-stronghold victory target. The prototype
provisionally keeps those factions independent: their inventories, holdings and
alliances do not automatically merge. The sources do not explicitly settle
that composition or all player-count interactions. It is an implementation
interpretation, not an official ruling; see [scope and limits](TWO_PLAYER_TWO_FACTIONS.md).
## Juice of Sapho and an already-started Advanced Charity turn — 27 October 2026
Extend the provisional own-turn Last composition to a clean ordered Charity
+15 -6
View File
@@ -2,7 +2,7 @@
## Status and authority
**Missing; design contract only.** The [GF9 classic rulebook, printed p. 6](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=6)
**Prototyped; wider rules and acceptance remain open.** The [GF9 classic rulebook, printed p. 6](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=6)
offers a two-player setup in which each person plays two factions, with the
two-player four-stronghold victory requirement. The user-authorized revised
Advanced source, physical p. 4 (printed p. 2), offers the same choice and
@@ -36,8 +36,8 @@ original interpretation and do not receive invented controller assignments.
Expose this as an explicit two-player setup option. Let each person create or
join their first seat and securely add/select their second faction before
readiness, with clear names and a persistent active-faction indicator. Avoid
claiming support for the mode until setup, both private faction views, legal
actions, turn continuation and victory all work together. The existing model
claiming complete support for the mode until complete games and wider
interactions are verified. The existing model
still permits ordinary two-seat play and separate one-faction controllers.
## Separate controllers from faction seats
@@ -66,13 +66,14 @@ Today `db/schema.ts` makes `seats.token_hash` the primary key;
`db/rooms.ts` authenticates a token to one `player_id` and `act` rechecks that
exact unrevoked room/player/token tuple before the versioned action. The normal
`joinRoom` path returns `alreadySeated` for an authenticated caller. Room API
routes currently use a single room-scoped HttpOnly cookie. Reusing one token
routes retain the original room-scoped HttpOnly cookie and use separately
scoped HttpOnly cookies for added faction seats. Reusing one token
for two database rows, trusting a caller-supplied seat ID as authorization, or
projecting both seats' private information into one general view would break
those existing boundaries.
One workable implementation is an explicit authenticated add-second-seat
operation that atomically validates the lobby/controller assignment and creates
The authenticated add-second-seat operation atomically validates the
lobby/controller assignment and creates
the second distinct seat credential. Provision separately random, room-scoped
HttpOnly credentials for both faction seats. A public active-seat selector may
choose which credential to present, but every API read, action, message,
@@ -94,6 +95,14 @@ the UI explicit about which faction owns each pending decision. Existing
autopilot can operate a faction seat only under its existing authorization;
until full AI refinement, each offered decision needs a minimal legal bot path.
The current prototype requires human seats, permits ordinary existing
seat-autopilot, and does not yet support accepting a handover into a browser
already holding a different active seat for the same room. The mode cannot be
disabled once a secondary faction is added. Recovery replaces an existing
scoped credential for the recovered seat without overwriting another valid
legacy seat. Comprehensive recovery, revocation and custody acceptance remain
open.
## Integration and evidence
- Implement one shared controller/seat contract through lobby creation,
+87 -2
View File
@@ -1,3 +1,5 @@
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
@@ -1406,6 +1408,8 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
/** Explicit two-human/four-faction setup; absent from ordinary and legacy games. */
twoPlayerControllers?: TwoPlayerControllers;
/** Explicit final turn after an agreed extension; absent means turn ten. */
victoryFinalTurn?: number;
/** Agreed addition to the ordinary stronghold win targets. Older games omit it. */
@@ -5675,7 +5679,32 @@ export function createGame(
aid: {},
};
}
export function joinGame(g: Game, p: Player) {
function twoPlayerRule<T>(operation: () => T): T {
try {
return operation();
} catch (error) {
if (error instanceof TwoPlayerControllersError) throw new RuleError(error.message);
throw error;
}
}
/** Reject stale or ambiguous ownership before either projecting or mutating a game. */
function twoPlayerControllersIntegrity(g: Game): void {
if (g.twoPlayerControllers === undefined) return;
const seats = g.players.map(player => player.id);
twoPlayerRule(() => {
if (g.status === 'lobby') validateTwoPlayerControllersLobby(g.twoPlayerControllers, seats);
else validateTwoPlayerControllersComplete(g.twoPlayerControllers, seats);
});
const assigned = g.twoPlayerControllers.controllers.flatMap(controller => controller.seats);
requireRule(assigned.length === seats.length && seats.every(seat => assigned.includes(seat)),
'Every two-person faction must belong to exactly one controller.');
requireRule(g.players.every(player => !player.bot),
'Two-person setup requires human faction seats.');
}
function joinFactionSeat(g: Game, p: Player, controllerId?: string) {
twoPlayerControllersIntegrity(g);
requireRule(g.status === 'lobby', 'This game has already started.');
requireRule(g.players.length < 6, 'This table has six players.');
requireRule(!g.players.some((x) => x.id === p.id), 'Already seated.');
@@ -5688,11 +5717,31 @@ export function joinGame(g: Game, p: Player) {
g.expansions.includes(faction(p.faction).expansion),
'The host has not enabled that expansion.',
);
let mapping = g.twoPlayerControllers;
if (mapping) {
requireRule(!p.bot, 'Two-person setup requires human faction seats.');
mapping = twoPlayerRule(() => addTwoPlayerControlledSeat(mapping!,
[...g.players.map(player => player.id), p.id], controllerId ?? p.id, p.id));
} else requireRule(controllerId === undefined, 'Enable two-person setup before adding a second faction.');
g.players.push(p);
if (mapping) g.twoPlayerControllers = mapping;
g.playerPositions = normalizedPlayerPositions(g);
g.players.forEach((x) => (x.ready = !!x.bot));
log(g, `${p.name} joined as ${faction(p.faction).name}.`);
}
export function joinGame(g: Game, p: Player) {
joinFactionSeat(g, p);
}
/** The caller must authenticate actorId's distinct seat credential separately. */
export function joinControlledFaction(g: Game, p: Player, actorId: string) {
twoPlayerControllersIntegrity(g);
requireRule(g.status === 'lobby' && g.twoPlayerControllers,
'Enable two-person setup before adding a second faction.');
const controller = g.twoPlayerControllers.controllers.find(entry => entry.seats.includes(actorId));
requireRule(controller, 'This faction has no two-person controller.');
joinFactionSeat(g, p, controller.id);
}
function setMovementDiscardKnowledge(g: Game, knowledge: MovementDiscardKnowledge | undefined) {
if (knowledge === undefined) delete g.movementDiscardKnowledge;
else g.movementDiscardKnowledge = knowledge;
@@ -10285,13 +10334,17 @@ function requireFreshBaseRuntime(g: Game) {
// unowned optional components are checked separately; gameplay fields stay fresh.
const configuration = new Set<keyof Game>(['code', 'version', 'host', 'players', 'playerPositions',
'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards',
'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery', 'lobbyOptions', 'kullDefaultPolicy']);
'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery', 'lobbyOptions', 'kullDefaultPolicy',
'twoPlayerControllers']);
const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[];
requireRule(keys.every(key => configuration.has(key) ||
JSON.stringify(g[key]) === JSON.stringify(fresh[key])),
'Starting a base table cannot retain existing game resources, histories or continuations.');
}
function start(g: Game, advancedPreview = false): Game {
twoPlayerControllersIntegrity(g);
if (g.twoPlayerControllers) twoPlayerRule(() =>
validateTwoPlayerControllersComplete(g.twoPlayerControllers, g.players.map(player => player.id)));
requireRule(g.players.length >= 2, 'At least two players are needed.');
requireRule(
g.players.every((p) => p.ready),
@@ -39867,6 +39920,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
twoPlayerControllersIntegrity(state);
victoryContinuationIntegrity(state);
bidPromiseIntegrity(state);
phaseBidIntegrity(state);
@@ -40482,6 +40536,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
twoPlayerControllersIntegrity(state);
victoryContinuationIntegrity(state);
if (state.victoryContinuation) return state;
bidPromiseIntegrity(state);
@@ -42808,6 +42863,29 @@ function applyActionInner(
return g;
}
if (g.status === 'lobby') {
if (t === 'twoPlayerMode') {
requireRule(id === g.host, 'Only the host can change two-person setup.');
requireRule(typeof action.enabled === 'boolean' &&
Object.keys(action).every(key => key === 'type' || key === 'enabled'),
'Choose whether each of two people controls two factions.');
if (action.enabled === !!g.twoPlayerControllers) return g;
if (action.enabled) {
requireRule(g.players.length <= 2 && g.players.every(player => !player.bot),
'Enable two-person setup with at most two human faction seats and no AI seats.');
const seats = g.players.map(player => player.id);
let mapping = createTwoPlayerControllers();
for (const seat of seats) mapping = twoPlayerRule(() =>
addTwoPlayerControlledSeat(mapping, seats, seat, seat));
g.twoPlayerControllers = mapping;
} else {
requireRule(g.twoPlayerControllers!.controllers.every(controller => controller.seats.length === 1),
'Two-person setup cannot be disabled after second faction seats are added.');
delete g.twoPlayerControllers;
}
g.players.forEach(player => (player.ready = false));
log(g, `${p.name} ${action.enabled ? 'enabled' : 'disabled'} the provisional two-person, four-faction setup. Human readiness was cleared.`);
return g;
}
if (t === 'rules') {
requireRule(id === g.host, 'Only the host can change the rules mode.');
requireRule(typeof action.advanced === 'boolean' &&
@@ -44690,6 +44768,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string, now?: number) {
twoPlayerControllersIntegrity(state);
victoryContinuationIntegrity(state);
revivalOrderIntegrity(state);
charityOrderIntegrity(state);
@@ -44988,6 +45067,12 @@ export function viewGame(state: Game, id: string, now?: number) {
version: g.version,
host: g.host,
status: g.status,
twoPlayerControllers: g.twoPlayerControllers ?? null,
controlledFactionSeats: g.twoPlayerControllers
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
controller.seats.includes(id) && controller.seats.includes(player.id)))
.map(player => ({ id: player.id, name: player.name, faction: player.faction }))
: [],
setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
setupPending: setupPending(g),
leaderSkills: projectedLeaderSkills(g, id),
+22 -1
View File
@@ -4439,7 +4439,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Stronghold targets, storm-contested occupation, prediction and final-turn fallback.',
steps: [
'Victory is checked when the Mentat pause begins. A faction needs three strongholds, or four when allied or playing a two-player game. Allies combine distinct qualifying strongholds.',
'The optional two-person setup with two factions per person is not yet available; ordinary two-seat games use the four-stronghold target.',
'In the provisional two-person setup, each person controls two separately played factions and switches between their seats. They are not automatically allied and do not pool holdings unless they form an alliance. Each side needs four strongholds. Ordinary two-seat games also use four.',
'Ecaz and its reciprocal ally can also win by jointly occupying three strongholds: both factions need non-advisor presence in each, without opposing fighters. The ordinary four-stronghold alliance route remains available. Occupy cancellation does not remove this victory condition. A concealed No-Field supplies public presence; its secret value is not revealed by this check.',
'The separately marked fresh native Ecaz Homeworld preview has another printed route: native Ecaz must currently have at least seven reserves, the reciprocal alliance must jointly hold one actual uncontested stronghold, and it must occupy worlds of two other native factions. Two Emperor worlds count as one faction. This joins normal winners before a correct BG prediction or final-turn fallback; it adds no ordinary stronghold points.',
'Opposing forces may survive together in a stronghold covered by storm. That contested stronghold does not count for either side. A sole occupant still counts it even under storm.',
@@ -4460,6 +4460,27 @@ export const RULE_TOPICS: RuleTopic[] = [
'faction-beneGesserit',
],
},
{
id: 'two-person-four-factions',
title: 'Two people, two factions each',
category: 'Getting started',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Two people each control two independent faction seats.',
steps: [
'The host enables the option before adding extra factions. Each person joins one seat and adds a second, then switches active faction to ready and play each seat.',
'The provisional interpretation keeps the factions separate and does not automatically ally them. An individual faction or alliance needs four strongholds.',
'This prototype requires human seats, cannot be disabled after a second seat is added, and does not yet establish every player-count interaction.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Two controllers own four independently authenticated seats; the shared victory quote uses the validated controller count. Other player-count rules remain provisional.', evidence: ['game/two-player-controllers.ts', 'game/engine.ts', 'game/victory-progress.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Lobby controls add and select seats, each with its own view and actions; wider accessibility and complete-game acceptance remain open.', evidence: ['components/two-player-controls.tsx', 'app/page.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing authorized human-seat autopilot remains available; independent bot seats and strategic calibration for this mode remain unfinished.', evidence: ['game/engine.ts', 'docs/AI_DEVELOPMENT_PLAN.md'] },
{ area: 'Documentation', status: 'Partial', detail: 'The provisional interpretation, sources and limitations are recorded.', evidence: ['docs/TWO_PLAYER_TWO_FACTIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused setup, first-turn, victory, seat selection, action and recovery cases exercise the prototype. Full games, combinations, broader custody and deployed acceptance remain open.', evidence: ['tests/two-player-engine.test.ts', 'tests/two-player-seat-http.test.ts', 'tests/victory-progress.test.ts'] },
],
related: ['victory', 'mentat'],
},
{
id: 'fremen-ecaz-endgame',
title: 'Fremen and Ecaz: the final-turn exception',
+134
View File
@@ -0,0 +1,134 @@
/** Explicit two-person setup: faction seats remain separate physical players.
* Controller IDs are their original, independently authenticated first seats;
* this public mapping is not itself a credential or an alliance. */
export type TwoPlayerController = {
id: string;
seats: string[];
};
export type TwoPlayerControllers = {
version: 1;
controllers: TwoPlayerController[];
};
export class TwoPlayerControllersError extends Error {
constructor(message: string) {
super(message);
this.name = 'TwoPlayerControllersError';
}
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value) &&
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.trim() === value;
function requireControllers(condition: unknown, message: string): asserts condition {
if (!condition) throw new TwoPlayerControllersError(message);
}
function validateSeats(seats: readonly string[]): void {
requireControllers(Array.isArray(seats) && seats.length <= 4 && seats.every(identifier) &&
new Set(seats).size === seats.length,
'The two-person setup needs at most four distinct seated faction IDs.');
}
/** An empty mapping is useful while the lobby is being assembled. It does not
* authorize any seat; the room layer must authenticate every seat separately. */
export function createTwoPlayerControllers(): TwoPlayerControllers {
return { version: 1, controllers: [] };
}
/** Validate lobby-stage assignments. Unassigned seated factions are allowed
* while the lobby is assembled, but every assignment must refer to a distinct
* current seat. An existing controller's first seat is always its identity. */
export function validateTwoPlayerControllersLobby(
value: unknown,
seats: readonly string[],
): asserts value is TwoPlayerControllers {
validateSeats(seats);
requireControllers(record(value) && Object.keys(value).sort().join(',') === 'controllers,version' &&
value.version === 1 && Array.isArray(value.controllers) && value.controllers.length <= 2,
'The two-person controller mapping is malformed.');
const identities = new Set<string>();
const assigned = new Set<string>();
for (const controller of value.controllers) {
requireControllers(record(controller) && Object.keys(controller).sort().join(',') === 'id,seats' &&
identifier(controller.id) && Array.isArray(controller.seats) &&
controller.seats.length >= 1 && controller.seats.length <= 2 &&
controller.seats.every(identifier) && controller.seats[0] === controller.id &&
controller.seats.every(seat => seats.includes(seat)),
'Each controller must retain its original primary seat and at most one other seated faction.');
requireControllers(!identities.has(controller.id), 'Each controller must have a distinct primary seat.');
identities.add(controller.id);
for (const seat of controller.seats) {
requireControllers(!assigned.has(seat), 'Each faction seat can belong to only one controller.');
assigned.add(seat);
}
}
}
/** Before play, require exactly two controllers, two factions each, and a
* complete partition of the four actual faction seats. */
export function validateTwoPlayerControllersComplete(
value: unknown,
seats: readonly string[],
): asserts value is TwoPlayerControllers {
validateTwoPlayerControllersLobby(value, seats);
requireControllers(seats.length === 4 && value.controllers.length === 2 &&
value.controllers.every(controller => controller.seats.length === 2),
'Two-person play needs exactly two controllers with two faction seats each.');
}
/** Add one actual faction to a controller without mutating either input.
* A new controller can only be created by adding its own original seat first. */
export function addTwoPlayerControlledSeat(
value: TwoPlayerControllers,
seats: readonly string[],
controllerId: string,
seatId: string,
): TwoPlayerControllers {
validateTwoPlayerControllersLobby(value, seats);
requireControllers(identifier(controllerId) && identifier(seatId) && seats.includes(seatId),
'Choose a currently seated faction and its actual controller.');
requireControllers(!value.controllers.some(controller => controller.seats.includes(seatId)),
'This faction seat already belongs to a controller.');
const next: TwoPlayerControllers = { version: 1, controllers: value.controllers.map(controller =>
({ id: controller.id, seats: [...controller.seats] })) };
const existing = next.controllers.find(controller => controller.id === controllerId);
if (existing) {
requireControllers(existing.seats.length < 2, 'A controller already has two faction seats.');
existing.seats.push(seatId);
} else {
requireControllers(next.controllers.length < 2 && controllerId === seatId,
'Start each new controller with its own distinct primary seat.');
next.controllers.push({ id: controllerId, seats: [seatId] });
}
validateTwoPlayerControllersLobby(next, seats);
return next;
}
/** Absence means the existing one-faction-per-person game. An explicit mapping
* must be complete before it can influence a rules calculation. */
export function physicalControllerCount(
seats: readonly string[],
value?: TwoPlayerControllers | null,
): number {
requireControllers(Array.isArray(seats) && seats.length >= 1 && seats.length <= 6 &&
seats.every(identifier) && new Set(seats).size === seats.length,
'Count distinct actual faction seats.');
if (value === undefined || value === null) return seats.length;
validateTwoPlayerControllersComplete(value, seats);
return value.controllers.length;
}
/** Source-backed two-player threshold. Allied sides keep their existing
* four-stronghold target; the caller applies any agreed target increment. */
export function controllerStrongholdTarget(
seats: readonly string[],
value: TwoPlayerControllers | null | undefined,
allied: boolean,
): 3 | 4 {
requireControllers(typeof allied === 'boolean', 'Identify whether this victory side is allied.');
const controllers = physicalControllerCount(seats, value);
return allied || controllers === 2 ? 4 : 3;
}
+8 -2
View File
@@ -9,6 +9,7 @@ import { gameTerritories } from './board';
import { fighterCount } from './advisors';
import { TECH_TOKENS } from './tech-tokens';
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
import { controllerStrongholdTarget, type TwoPlayerControllers } from './two-player-controllers';
export type StrongholdProgress = {
player: string;
@@ -45,7 +46,10 @@ export function countsForStrongholdVictory(id: string) {
* victory, combat resolution, resource mutation or Stronghold Card ownership is
* considered. Mandatory advisor releases are returned for the caller to commit. */
export function strongholdProgress(
g: BoardContext & Pick<Game, 'techTokens'> & { victoryExtraStrongholds?: number },
g: BoardContext & Pick<Game, 'techTokens'> & {
victoryExtraStrongholds?: number;
twoPlayerControllers?: TwoPlayerControllers;
},
): { released: AdvisorRelease[]; progress: StrongholdProgress[] } {
try {
requireProgress(
@@ -129,7 +133,9 @@ export function strongholdProgress(
members.some((member) =>
TECH_TOKENS.every(({ id }) => tech[id].owner === member),
);
const target = (partner || players.length === 2 ? 4 : 3) + extra;
const target = controllerStrongholdTarget(
players.map((player) => player.id), g.twoPlayerControllers, !!partner,
) + extra;
return {
player: id,
members,
+2
View File
@@ -23,6 +23,7 @@ import {
type StrongholdState,
} from './stronghold-cards';
import { ecazHomeworldVictoryProgress, type EcazHomeworldVictoryContext, type EcazHomeworldVictoryProgress } from './ecaz-homeworld-victory';
import type { TwoPlayerControllers } from './two-player-controllers';
export class VictoryQuoteError extends Error {}
function requireVictory(
@@ -41,6 +42,7 @@ export type VictoryContext = Omit<BoardContext, 'players'> &
'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds' | 'victoryFinalTurn'
> & {
players: readonly (BoardSeat & Pick<Player, 'prediction'>)[];
twoPlayerControllers?: TwoPlayerControllers;
};
export type VictoryQuote = {
released: AdvisorRelease[];
+37
View File
@@ -0,0 +1,37 @@
import { authenticate, type SeatAuth } from '@/db/rooms';
import { RuleError } from '@/game/engine';
const seatIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
export function selectedSeatId(req: Request): string | null {
const id = req.headers.get('X-Dune-Seat');
if (id !== null && !seatIdPattern.test(id)) throw new RuleError('The selected seat is invalid.');
return id;
}
export function seatCookie(req: Request, name: string): string {
return req.headers.get('cookie')?.split(';').map(part => part.trim())
.find(part => part.startsWith(`${name}=`))?.slice(name.length + 1) ?? '';
}
/** All browser-held room credentials, used to preserve handover restrictions. */
export function roomSeatCookies(req: Request, code: string): string[] {
const prefix = `dune_${code}`;
return (req.headers.get('cookie') ?? '').split(';').map(part => part.trim())
.flatMap(part => {
const equals = part.indexOf('=');
if (equals < 0) return [];
const name = part.slice(0, equals);
if (name !== prefix && !(name.startsWith(`${prefix}_`) && seatIdPattern.test(name.slice(prefix.length + 1)))) return [];
return [part.slice(equals + 1)];
});
}
/** The selector chooses a credential, never a seat without authentication. */
export async function authenticateSelectedSeat(req: Request, code: string): Promise<SeatAuth> {
const id = selectedSeatId(req);
const token = (id && seatCookie(req, `dune_${code}_${id}`)) || seatCookie(req, `dune_${code}`);
const auth = await authenticate(code, token);
if (id && auth.playerId !== id) throw new RuleError('Your selected seat could not be verified.');
return auth;
}
+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';