Prototype two-player four-faction setup
This commit is contained in:
1 parent
70ef142577
commit
24255905ed
28 files changed
+1087
-69
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
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 authenticate(code, token);
|
||||
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 !== input.playerId)
|
||||
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,
|
||||
'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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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' : ''}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user