Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
authenticate,
|
||||
recoverSeat,
|
||||
SeatControlError,
|
||||
setRecoveryKey,
|
||||
} from '@/db/rooms';
|
||||
import { RuleError } from '@/game/engine';
|
||||
const noStore = { 'Cache-Control': 'no-store' };
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
if (req.headers.get('origin') && req.headers.get('origin') !== url.origin)
|
||||
throw new SeatControlError(
|
||||
'Invalid request origin.',
|
||||
'INVALID_ORIGIN',
|
||||
403,
|
||||
);
|
||||
const code = url.pathname.split('/').at(-2)!.toUpperCase();
|
||||
if (!/^[A-Z2-9]{8}$/.test(code))
|
||||
throw new SeatControlError(
|
||||
'Enter the eight-character room code.',
|
||||
'INVALID_CONTROL_REQUEST',
|
||||
400,
|
||||
);
|
||||
const raw = await req.text();
|
||||
if (raw.length > 4000)
|
||||
throw new SeatControlError(
|
||||
'Request too large.',
|
||||
'REQUEST_TOO_LARGE',
|
||||
413,
|
||||
);
|
||||
const body: unknown = JSON.parse(raw);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new SeatControlError(
|
||||
'Choose a recovery operation.',
|
||||
'INVALID_CONTROL_REQUEST',
|
||||
400,
|
||||
);
|
||||
const input = body as Record<string, unknown>;
|
||||
if (input.type === 'setRecoveryKey') {
|
||||
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);
|
||||
return Response.json(
|
||||
await setRecoveryKey(code, auth, input.version, input.recoverySecret),
|
||||
{ headers: noStore },
|
||||
);
|
||||
}
|
||||
if (input.type !== 'recoverSeat')
|
||||
throw new SeatControlError(
|
||||
'Choose a recovery operation.',
|
||||
'INVALID_CONTROL_REQUEST',
|
||||
400,
|
||||
);
|
||||
const result = await recoverSeat(code, {
|
||||
playerId: input.playerId,
|
||||
recoverySecret: input.recoverySecret,
|
||||
operationId: input.operationId,
|
||||
newSessionToken: input.newSessionToken,
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
view: result.view,
|
||||
recovered: result.recovered,
|
||||
replayed: result.replayed,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
...noStore,
|
||||
'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${url.protocol === 'https:' ? '; Secure' : ''}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
error instanceof RuleError
|
||||
? error.message
|
||||
: error instanceof SyntaxError
|
||||
? 'Invalid JSON.'
|
||||
: 'The server could not complete seat recovery.',
|
||||
code:
|
||||
error instanceof SeatControlError
|
||||
? error.code
|
||||
: error instanceof RuleError
|
||||
? 'INVALID_SESSION'
|
||||
: error instanceof SyntaxError
|
||||
? 'INVALID_CONTROL_REQUEST'
|
||||
: 'SERVER_ERROR',
|
||||
},
|
||||
{
|
||||
status:
|
||||
error instanceof SeatControlError
|
||||
? error.status
|
||||
: error instanceof RuleError
|
||||
? 409
|
||||
: error instanceof SyntaxError
|
||||
? 400
|
||||
: 500,
|
||||
headers: noStore,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
readSeatView,
|
||||
authenticate,
|
||||
joinRoom,
|
||||
act,
|
||||
RoomEntryError,
|
||||
continueRoomBots,
|
||||
continueRoomAutomatic,
|
||||
needsAutomaticRoomRecovery,
|
||||
type SeatAuth,
|
||||
} from '@/db/rooms';
|
||||
import { RuleError } from '@/game/engine';
|
||||
import type { FactionId } from '@/game/catalog';
|
||||
import { waitUntil } from 'cloudflare:workers';
|
||||
// Coalesce overlapping polls inside this isolate. Database CAS still fences
|
||||
// other isolates, restarts, seat recovery, and human actions.
|
||||
const continuations = new Map<string, Promise<void>>();
|
||||
function resumeRoom(code: string): Promise<void> {
|
||||
const existing = continuations.get(code);
|
||||
if (existing) return existing;
|
||||
const pending = (async () => {
|
||||
await continueRoomAutomatic(code);
|
||||
await continueRoomBots(code);
|
||||
})().finally(() => {
|
||||
if (continuations.get(code) === pending) continuations.delete(code);
|
||||
});
|
||||
continuations.set(code, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
const codeOf = (req: Request) => {
|
||||
const code = new URL(req.url).pathname.split('/').pop()!.toUpperCase();
|
||||
if (!/^[A-Z2-9]{8}$/.test(code))
|
||||
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(
|
||||
{
|
||||
...(e instanceof RoomEntryError ? { code: e.code } : {}),
|
||||
error:
|
||||
e instanceof RuleError
|
||||
? e.message
|
||||
: e instanceof SyntaxError
|
||||
? 'Invalid JSON.'
|
||||
: 'The game server could not complete this request.',
|
||||
},
|
||||
{
|
||||
status:
|
||||
e instanceof RoomEntryError
|
||||
? e.status
|
||||
: e instanceof RuleError
|
||||
? 409
|
||||
: e instanceof SyntaxError
|
||||
? 400
|
||||
: 500,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
},
|
||||
);
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const code = codeOf(req),
|
||||
id = await authenticate(code, tokenOf(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.
|
||||
if (view.botsPending || needsAutomaticRoomRecovery(view))
|
||||
waitUntil(resumeRoom(code));
|
||||
return Response.json(view, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (e) {
|
||||
return failure(e);
|
||||
}
|
||||
}
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
if (
|
||||
req.headers.get('origin') &&
|
||||
req.headers.get('origin') !== new URL(req.url).origin
|
||||
)
|
||||
return Response.json(
|
||||
{ error: 'Invalid request origin.' },
|
||||
{ status: 403 },
|
||||
);
|
||||
const code = codeOf(req);
|
||||
const raw = await req.text();
|
||||
if (raw.length > 8000)
|
||||
return Response.json({ error: 'Request too large.' }, { status: 413 });
|
||||
const body = JSON.parse(raw);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new RuleError('The request is invalid.');
|
||||
if (body.type === 'join') {
|
||||
const existing = tokenOf(req, code);
|
||||
let auth: SeatAuth | null = null;
|
||||
if (existing) {
|
||||
try {
|
||||
auth = await authenticate(code, existing);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
}
|
||||
}
|
||||
if (body.entry === undefined && auth) {
|
||||
return Response.json(await readSeatView(code, auth), {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
if (typeof body.name !== 'string' || typeof body.faction !== 'string')
|
||||
throw new RuleError('Enter your name and faction.');
|
||||
const r = await joinRoom(
|
||||
code,
|
||||
body.name,
|
||||
body.faction as FactionId,
|
||||
body.entry,
|
||||
auth,
|
||||
);
|
||||
return Response.json(
|
||||
{
|
||||
...r.view,
|
||||
...(r.entryReceipt ? { entryReceipt: r.entryReceipt } : {}),
|
||||
...(r.alreadySeated ? { alreadySeated: true } : {}),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
...(r.token
|
||||
? {
|
||||
'Set-Cookie': `dune_${code}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`,
|
||||
}
|
||||
: {}),
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const id = await authenticate(code, tokenOf(req, code));
|
||||
if (
|
||||
!Number.isSafeInteger(body.version) ||
|
||||
!body.action ||
|
||||
typeof body.action !== 'object'
|
||||
)
|
||||
throw new RuleError('The action is invalid.');
|
||||
const view = await act(code, id, body.version, body.action);
|
||||
if (view.botsPending || needsAutomaticRoomRecovery(view))
|
||||
waitUntil(resumeRoom(code));
|
||||
return Response.json(view, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (e) {
|
||||
return failure(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createRoom, RoomEntryError } from '@/db/rooms';
|
||||
import { RuleError } from '@/game/engine';
|
||||
import type { FactionId } from '@/game/catalog';
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
if (
|
||||
req.headers.get('origin') &&
|
||||
req.headers.get('origin') !== new URL(req.url).origin
|
||||
)
|
||||
return Response.json(
|
||||
{ error: 'Invalid request origin.' },
|
||||
{ status: 403 },
|
||||
);
|
||||
const raw = await req.text();
|
||||
if (raw.length > 4000)
|
||||
return Response.json({ error: 'Request too large.' }, { status: 413 });
|
||||
const body = JSON.parse(raw);
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== 'object' ||
|
||||
Array.isArray(body) ||
|
||||
typeof body.name !== 'string' ||
|
||||
typeof body.faction !== 'string' ||
|
||||
!Array.isArray(body.expansions) ||
|
||||
typeof body.advanced !== 'boolean'
|
||||
)
|
||||
throw new RuleError('Enter a name, faction and rules selection.');
|
||||
const result = await createRoom(
|
||||
body.name,
|
||||
body.faction as FactionId,
|
||||
body.advanced,
|
||||
body.expansions,
|
||||
body.entry,
|
||||
);
|
||||
return Response.json(
|
||||
{
|
||||
...result.view,
|
||||
...(result.entryReceipt ? { entryReceipt: result.entryReceipt } : {}),
|
||||
},
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
'Set-Cookie': `dune_${result.view.code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${result.view.code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.json(
|
||||
{
|
||||
...(e instanceof RoomEntryError ? { code: e.code } : {}),
|
||||
error:
|
||||
e instanceof RuleError
|
||||
? e.message
|
||||
: e instanceof SyntaxError
|
||||
? 'Invalid JSON.'
|
||||
: 'Unable to create the room. Please retry.',
|
||||
},
|
||||
{
|
||||
status:
|
||||
e instanceof RoomEntryError
|
||||
? e.status
|
||||
: e instanceof RuleError || e instanceof SyntaxError
|
||||
? 400
|
||||
: 500,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user