Prototype two-player four-faction setup
This commit is contained in:
1 parent
70ef142577
commit
24255905ed
28 files changed
+1091
-73
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { requestOrigin } from '@/lib/request-origin';
|
||||
import { authenticateSelectedSeat, selectedSeatId, seatCookie, roomSeatCookies } from '@/lib/selected-seat';
|
||||
import {
|
||||
authenticate,
|
||||
createSeatHandover,
|
||||
revokeSeatHandover,
|
||||
claimSeatHandover,
|
||||
@@ -49,13 +49,7 @@ export async function POST(req: Request) {
|
||||
400,
|
||||
);
|
||||
const input = body as Record<string, unknown>;
|
||||
const token =
|
||||
req.headers
|
||||
.get('cookie')
|
||||
?.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`dune_${code}=`))
|
||||
?.split('=')[1] ?? '';
|
||||
const selector = selectedSeatId(req);
|
||||
if (
|
||||
input.type === 'setSeatAiDelegate' ||
|
||||
input.type === 'revokeSeatAiDelegate' ||
|
||||
@@ -67,7 +61,7 @@ export async function POST(req: Request) {
|
||||
'INVALID_CONTROL_REQUEST',
|
||||
400,
|
||||
);
|
||||
const auth = await authenticate(code, token);
|
||||
const auth = await authenticateSelectedSeat(req, code);
|
||||
const result =
|
||||
input.type === 'setSeatAiDelegate'
|
||||
? await setSeatAiDelegate(code, auth, input.version, {
|
||||
@@ -95,7 +89,7 @@ export async function POST(req: Request) {
|
||||
input.type === 'createSeatHandover' ||
|
||||
input.type === 'revokeSeatHandover'
|
||||
) {
|
||||
const auth = await authenticate(code, token);
|
||||
const auth = await authenticateSelectedSeat(req, code);
|
||||
if (input.type === 'createSeatHandover')
|
||||
return Response.json(
|
||||
await createSeatHandover(code, auth, input.version, {
|
||||
@@ -120,20 +114,44 @@ export async function POST(req: Request) {
|
||||
'INVALID_CONTROL_REQUEST',
|
||||
400,
|
||||
);
|
||||
if (input.type === 'claimSeatHandover' && token) {
|
||||
let existing;
|
||||
if (typeof input.playerId !== 'string' || !input.playerId || input.playerId.length > 100)
|
||||
throw new SeatControlError('Provide a valid player ID.', 'INVALID_CONTROL_REQUEST', 400);
|
||||
const targetId = input.playerId;
|
||||
if (input.type === 'claimSeatHandover') {
|
||||
// A caller cannot evade the existing-browser restriction by selecting a
|
||||
// nonexistent scoped cookie while retaining another legacy credential.
|
||||
for (const cookie of roomSeatCookies(req, code)) {
|
||||
if (!cookie) continue;
|
||||
let existing;
|
||||
try {
|
||||
existing = await authenticateSelectedSeat(new Request(req.url, { headers: { cookie: `dune_${code}=${cookie}` } }), code);
|
||||
} catch (error) {
|
||||
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
|
||||
}
|
||||
if (existing && existing.playerId !== targetId)
|
||||
throw new SeatControlError(
|
||||
'This browser already controls another seat in this room. Use a separate browser profile to accept the handover.',
|
||||
'ALREADY_SEATED', 409,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (selector && selector !== targetId)
|
||||
throw new SeatControlError('The selected seat does not match this recovery request.', 'INVALID_CONTROL_REQUEST', 400);
|
||||
// Determine ownership before recovery rotates the old credential.
|
||||
const existingLegacy = seatCookie(req, `dune_${code}`);
|
||||
let legacyOtherSeat = false;
|
||||
if (existingLegacy) {
|
||||
try {
|
||||
existing = await authenticate(code, token);
|
||||
legacyOtherSeat = (await authenticateSelectedSeat(new Request(req.url, { headers: { cookie: `dune_${code}=${existingLegacy}` } }), code)).playerId !== targetId;
|
||||
} catch (error) {
|
||||
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
|
||||
}
|
||||
if (existing && existing.playerId !== input.playerId)
|
||||
throw new SeatControlError(
|
||||
'This browser already controls another seat in this room. Use a separate browser profile to accept the handover.',
|
||||
'ALREADY_SEATED',
|
||||
409,
|
||||
);
|
||||
}
|
||||
// A recovery claim may omit the selector. Replace any existing scoped
|
||||
// cookie for its target so that a stale token cannot shadow the new one.
|
||||
const hasTargetCookie = !!seatCookie(req, `dune_${code}_${targetId}`);
|
||||
const cookieName = hasTargetCookie || legacyOtherSeat
|
||||
? `dune_${code}_${targetId}` : `dune_${code}`;
|
||||
const result =
|
||||
input.type === 'claimSeatHandover'
|
||||
? await claimSeatHandover(code, {
|
||||
@@ -160,7 +178,7 @@ export async function POST(req: Request) {
|
||||
{
|
||||
headers: {
|
||||
...noStore,
|
||||
'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
|
||||
'Set-Cookie': `${cookieName}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>}
|
||||
|
||||
Reference in new issue
Block a user