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' },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+1901
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 & Tleilaxu, CHOAM & Richese, and Ecaz &
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { RulesReference } from '@/components/rules-reference';
|
||||
export default function Rules() {
|
||||
return <RulesReference />;
|
||||
}
|
||||
Reference in new issue
Block a user