Initial commit

This commit is contained in:
Quintine committed 2026-09-08 07:37:52 +10:00
commit 8c709fb52f
771 files changed
+222983

No files matched your search

+110
View File
@@ -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,
},
);
}
}
+158
View File
@@ -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);
}
}
+70
View File
@@ -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' },
},
);
}
}
+1901
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import './globals.css';
import { TooltipProvider } from '@/components/ui/tooltip';
export const metadata: Metadata = {
title: 'Dune — Arrakis Table',
description:
'A multiplayer table for classic Dune. Gather your rivals and battle for Arrakis.',
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>
<TooltipProvider delay={200}>{children}</TooltipProvider>
</body>
</html>
);
}
+1049
View File
File diff suppressed because it is too large. Load diff
+122
View File
@@ -0,0 +1,122 @@
import Link from 'next/link';
export default function Rules() {
return (
<main className="rule-page">
<Link href="/">← Return to the table</Link>
<h1>Rules coverage ledger</h1>
<p>
This project targets classic Gale Force Nine Dune, the advanced game,
and all three faction expansions. The current build is in development
and does not yet provide full rules compliance. Playable tables use
Basic rules with the six base factions; optional tech tokens require at
least three players. Advanced and expansion table starts remain disabled
while their remaining rules and complete games are verified.
</p>
<table>
<thead>
<tr>
<th>Area</th>
<th>Current implementation</th>
<th>Verification remaining</th>
</tr>
</thead>
<tbody>
{[
[
'Multiplayer',
'Persistent rooms, private seats, version checks, reconnects',
'Hosted deployment and broader game scenarios',
],
[
'Setup',
'Staged base setup: secret Bene Gesserit prediction before traitor dealing, all traitor choices before force placement, and starting treachery after placement. Advanced Fremen and advisor setup verified offline; existing saved setups keep their dealt cards',
'Complete Advanced rules and browser acceptance, expansion setup and optional-module setup; sampled complete games alone do not enable Advanced play',
],
[
'Storm & spice',
'Printed player circles and first-storm ordering audited; secret dials, double spice blow, Fremen forecasts, storm casualty choices, worms, Nexus and rides implemented',
'Full Advanced-game integration and remaining expansion storm/spice interactions',
],
[
'Economy',
'Charity, auctions, revival, Fremen ally revival, ally funding, Guild and Emperor income, and Advanced stronghold income',
'Auction clock and remaining power/payment timing windows',
],
[
'Movement',
'Territories, sector groups, routes, ornithopters, per-move Fremen Karama cancellation, Guild transport with private price/funding previews and contributor-based income, Advanced turn timing, BG free shipments, advisor stances and intrusion choices',
'Temporary allied entry with enforced departure, printed-board graph audit, remaining advisor timing and Guild cancellation cases, and combined expansion entry effects',
],
[
'Battle',
'Sealed plans, weapons, defenses, ties, traitors, Harkonnen ally support, Voice, element-only prescience, lasgun/shield, optional winner discards, spice combat, elite casualties, Kwisatz Haderach, captured leaders and advisor exclusions',
'Remaining advisor, special-power and timing interactions; complete Advanced battles within full games',
],
[
'Victory',
'Basic stronghold targets, alliances, Fremen/Guild special wins, prediction',
'Rulebook scenarios and contested stronghold checks',
],
[
'Treachery cards',
'Base inventory; Hajr, Ghola, Weather Control, Family Atomics, Harvester, Karama auctions, shipment benefit, faction response windows and Truthtrance card, traitor and current-spice facts, battle commitments with Ghola preparation and freeform questions',
'Remaining Karama powers, Truthtrance arbitrary commitments, other future card/power sequences and timing windows',
],
[
'Special Karama',
'Atreides plan inspection, Emperor revival, Fremen worm, Harkonnen hand exchange, Guild shipment stop, Tleilaxu revival prevention, Ixian stronghold relocation, CHOAM cash-in and Richese acquisition',
'Expansion powers; pre-reveal timing, worm/card sequencing, auction recovery and canceled-shipment settlement clarification',
],
[
'Ixians & Tleilaxu',
'Full fourteen-card inventory, tech tokens, special battle cards, Sandtrout, Thumper, Cheap Hero traitor, Amal, Tleilaxu Face Dancers, Zoal, revival commerce, foreign gholas and Ixian combat/substitution, movement, revival pricing, mobile stronghold and setup/bidding technology',
'Special-Karama timing/exception audit, mobile-stronghold collection/entry audits, complete-game setup, multi-Harvester/card-face audits and other auction modes, two-player tokens and expansion interactions',
],
[
'CHOAM & Richese',
'Partial mechanics: CHOAM charity, revival, Inflation, sales, allied exchanges, battle funding, five Worthless powers and Auditor inspection; Richese auctions, Black Market, No-Fields, allied shipments, card gifts and seven card effects; all six Stronghold Card effects, custody, inspectors and mobile choices',
'Kull Wahad, Auditor repeat-revival cycles, Semuta Drug, Mirror Weapon, Juice of Sapho, leader skills, complete Stronghold module combinations; remaining timing, payment and full-game checks',
],
[
'Ecaz & Moritani',
'Partial mechanics: Ambassador placement and five entry effects, Moritani Terror placement and supported reactions, Extortion, alliance powers, advanced leader assassination, and Moritani acquisition and battle use of Duke Vidal',
'Remaining Ambassador effects, Atomics/Aftermath, Ecaz setup and Vidal revival/control, competing entry effects, homeworlds, Nexus cards, discoveries and complete games',
],
[
'Presentation',
'Original desert artwork, interactive map, private hand, phase animations, portraits for all 60 ordinary leaders and a separate Duke Vidal portrait',
'Other special components, reveal animations, and broader responsive and accessibility review',
],
].map(([a, b, c]) => (
<tr key={a}>
<td>{a}</td>
<td>{b}</td>
<td>{c}</td>
</tr>
))}
</tbody>
</table>
<p>
Verification checkpoint: 1,631 rules, client and component tests and 152
persistence/API tests pass. Advanced setup has 456 roster, difficulty
and seating-order cases. A separate study completed 456 Advanced base
games across two through six players. Another 40 sampled base-faction
games with Stronghold Cards completed across every count and AI level,
with no rejected actions or stalls. Remaining Advanced rules and browser
acceptance, expansion games and optional modules still need
verification; these checks do not establish full rules compliance.
</p>
<h2>About this adaptation</h2>
<p>
Rules are being checked against the GF9 base rulebook, November 2020
FAQ, and the Ixians &amp; Tleilaxu, CHOAM &amp; Richese, and Ecaz &amp;
Moritani expansion rulebooks.
</p>
<p>
Board geometry adapted from the MIT-licensed Truthsayer project. Faction
and game names identify the board game being implemented. This is an
unofficial fan project.
</p>
</main>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { RulesReference } from '@/components/rules-reference';
export default function Rules() {
return <RulesReference />;
}