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 ||
|
||||
|
||||
Reference in new issue
Block a user