commit 8c709fb52fd9637eb78065590abd1e1b93281200
Author: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Tue Sep 8 07:37:52 2026 +1000
Initial commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..ef6f8998
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/.vinext/
+/out/
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# local environment
+.env*
+
+# deployment and generated files
+.vercel
+next-env.d.ts
+/dist/
+/.wrangler/
+/outputs/
+/work/
diff --git a/.openai/hosting.json b/.openai/hosting.json
new file mode 100644
index 00000000..048512a7
--- /dev/null
+++ b/.openai/hosting.json
@@ -0,0 +1,5 @@
+{
+ "project_id": "appgprj_6a9b7996c1a4819182205bafb527a97f",
+ "d1": "DB",
+ "r2": null
+}
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
new file mode 100644
index 00000000..2acd2356
--- /dev/null
+++ b/.oxfmtrc.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
+ "singleQuote": true,
+ "printWidth": 80,
+ "sortPackageJson": false,
+ "ignorePatterns": [
+ "package-lock.json",
+ "pnpm-lock.yaml",
+ "yarn.lock",
+ "bun.lock",
+ "bun.lockb"
+ ]
+}
diff --git a/.oxlintrc.json b/.oxlintrc.json
new file mode 100644
index 00000000..2e386fb5
--- /dev/null
+++ b/.oxlintrc.json
@@ -0,0 +1,68 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": [
+ "eslint",
+ "typescript",
+ "unicorn",
+ "oxc",
+ "react",
+ "import",
+ "jsx-a11y",
+ "nextjs"
+ ],
+ "categories": {
+ "correctness": "error"
+ },
+ "env": {
+ "builtin": true,
+ "browser": true,
+ "node": true
+ },
+ "options": {
+ "typeAware": true,
+ "typeCheck": true
+ },
+ "ignorePatterns": [
+ ".next/**",
+ ".vinext/**",
+ "out/**",
+ "build/**",
+ "dist/**",
+ "coverage/**",
+ "next-env.d.ts",
+ "components/ui/**",
+ "hooks/use-mobile.ts"
+ ],
+ "rules": {
+ "no-array-constructor": "error",
+ "no-var": "error",
+ "prefer-const": "error",
+ "prefer-rest-params": "error",
+ "prefer-spread": "error",
+ "import/no-anonymous-default-export": "warn",
+ "react/display-name": "error",
+ "react/jsx-no-comment-textnodes": "error",
+ "react/no-unescaped-entities": "error",
+ "react/react-compiler": "error",
+ "react/require-render-return": "error",
+ "react/rules-of-hooks": "error",
+ "typescript/ban-ts-comment": "error",
+ "typescript/no-deprecated": "error",
+ "typescript/no-empty-object-type": "error",
+ "typescript/no-explicit-any": "error",
+ "typescript/no-namespace": "error",
+ "typescript/no-require-imports": "error",
+ "typescript/no-unnecessary-type-constraint": "error",
+ "typescript/no-unsafe-function-type": "error"
+ },
+ "overrides": [
+ {
+ "files": [
+ "components/game-table.tsx"
+ ],
+ "rules": {
+ "jsx-a11y/prefer-tag-over-role": "off"
+ }
+ }
+ ]
+}
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..72816a36
--- /dev/null
+++ b/README.md
@@ -0,0 +1,57 @@
+# Dune — Arrakis Table
+
+An unofficial multiplayer adaptation of Gale Force Nine’s classic 2019 Dune, built with React, TypeScript, Vinext, and Cloudflare Workers/D1. **Development is ongoing; this is not yet a complete rules implementation.** Advanced and unfinished expansion starts remain disabled. See [implementation status](docs/IMPLEMENTATION_STATUS.md) for implemented systems, evidence, outstanding rules questions and release gates.
+
+## Run locally
+
+Use Node.js 22.13 or newer and npm. Install the locked dependencies and apply local migrations before starting the server:
+
+```sh
+npm ci
+npm run db:local
+npm run dev -- --host 0.0.0.0
+```
+
+Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels.
+
+Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project.
+
+## Saved seats and reconnecting
+
+The room cookie identifies your seat. Refreshing or reopening the invitation with that cookie restores your private hand and pending decisions. A failed restoration offers an explicit retry. Requests time out after 15 seconds; uncertain game actions are reconciled with a read rather than automatically repeated.
+
+Use **Protect your saved seat** to generate and privately save a recovery kit before enabling its key. Keep the complete kit separate from the invitation. **Recover a saved seat** uses that kit to restore the same faction after cookie loss and revokes the previous browser sessions for that seat. Recovery keeps existing forces, resources and sealed commitments. Anyone holding the kit can control that seat and see its private information.
+
+If recovery receives no confirmed response, keep the page open and retry the same recovery request. The server recognizes the receipt and does not rotate twice. Replacing the recovery key invalidates the older kit. Host-initiated takeover remains unfinished. An owning human seat can enable or disable its own AI control during a started game.
+
+New create/join requests save a private, tab-scoped retry record before sending. If the response is uncertain, keep the tab open and use the explicit retry; refresh preserves the same request. Confirmed success removes that record. Clearly rejected first requests return to the form for correction. A retry can recover the original room and seat without creating duplicates, but closing the tab or clearing its data can lose the proof. This record is separate from the long-term seat recovery kit.
+
+An uncertain saved-kit recovery keeps its exact retry details in memory and prevents conflicting navigation. If that recovery has become obsolete, deliberate abandonment releases the controls after explaining that it cannot undo a completed recovery. Keep a valid saved kit before discarding retry details.
+
+## Verify changes
+
+Run the development server before the persisted multiplayer suite:
+
+```sh
+npm test
+npm run test:multiplayer
+npm run typecheck
+npm run lint
+npm run build
+```
+
+The production build is written to `dist`. `npm start` runs that build with Wrangler; apply its database migrations to the intended storage environment before use. The included local migration command targets the development database, not a remote deployment.
+
+Reproduce the current AI study with:
+
+```sh
+node --import tsx tools/ai-calibration.ts --games 120 --seed 20260907 --out /tmp/dune-ai-calibration.json
+```
+
+See [AI calibration](docs/AI_CALIBRATION.md), [multiplayer audit](docs/MULTIPLAYER_AUDIT.md), [component inventory](docs/COMPONENT_INVENTORY.md), and [visual playtest](docs/VISUAL_PLAYTEST.md) for scope and limitations. Passing subsystem tests does not certify every rules combination.
+
+## Development maintenance
+
+During active development, preserve database state and perform a controlled server restart approximately hourly. Warn before interrupting human play and defer until a safe decision point. Verify server health and restoration afterward. No recurring automation is installed; the user requested leaving it removed. Fix causes of crashes and lost progress rather than relying on restarts.
+
+The application bundles its artwork, fonts and rules guidance. Source provenance and third-party notices live in developer documentation; the player-facing reference uses internal links. Publication remains gated on completion and integrated verification of the requested rules, expansions and critical player journeys.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 00000000..ae4e5c0d
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,23 @@
+Board geometry and connectivity adapted from https://github.com/marekyggdrasil/truthsayer (main). No raster artwork is included.
+
+MIT License
+
+Copyright (c) 2021 Marek
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/app/api/rooms/[code]/control/route.ts b/app/api/rooms/[code]/control/route.ts
new file mode 100644
index 00000000..a36004f5
--- /dev/null
+++ b/app/api/rooms/[code]/control/route.ts
@@ -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;
+ 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,
+ },
+ );
+ }
+}
diff --git a/app/api/rooms/[code]/route.ts b/app/api/rooms/[code]/route.ts
new file mode 100644
index 00000000..53a10add
--- /dev/null
+++ b/app/api/rooms/[code]/route.ts
@@ -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>();
+function resumeRoom(code: string): Promise {
+ 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);
+ }
+}
diff --git a/app/api/rooms/route.ts b/app/api/rooms/route.ts
new file mode 100644
index 00000000..417092eb
--- /dev/null
+++ b/app/api/rooms/route.ts
@@ -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' },
+ },
+ );
+ }
+}
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 00000000..24b34d63
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,1901 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+@import 'shadcn/tailwind.css';
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-border: var(--border);
+ --color-input: var(--border);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-ring: var(--primary);
+ --color-secondary: var(--muted);
+ --color-secondary-foreground: var(--foreground);
+ --color-accent: var(--muted);
+ --color-accent-foreground: var(--foreground);
+ --font-sans: Arial, Helvetica, sans-serif;
+}
+:root {
+ --background: #101311;
+ --foreground: #eeeae0;
+ --primary: #ddbc77;
+ --primary-foreground: #181b15;
+ --border: #34372e;
+ --muted: #23281f;
+ --muted-foreground: #a1a59a;
+ --radius: 0.25rem;
+ color-scheme: dark;
+}
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ background: var(--background);
+ color: var(--foreground);
+ font-family: Arial, Helvetica, sans-serif;
+ font-size: 16px;
+}
+button,
+a,
+input,
+select,
+textarea {
+ font: inherit;
+}
+button,
+a {
+ touch-action: manipulation;
+}
+button {
+ cursor: pointer;
+}
+button:disabled {
+ cursor: default;
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+button:focus-visible,
+a:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 4px;
+}
+.table-shell {
+ max-width: 1720px;
+ margin: auto;
+ padding: 0 44px;
+}
+.masthead {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ min-height: 108px;
+ border-bottom: 1px solid var(--border);
+}
+.wordmark {
+ font-size: 43px;
+ letter-spacing: 10px;
+ font-weight: 500;
+ line-height: 1;
+}
+.wordmark span {
+ display: block;
+ font-size: 10px;
+ letter-spacing: 4px;
+ color: #b7b7a7;
+ margin-top: 12px;
+}
+.masthead-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 14px;
+ color: #c6c6b9;
+}
+.masthead-right a {
+ display: flex;
+ gap: 9px;
+ align-items: center;
+ margin-left: 28px;
+}
+.status-dot {
+ width: 6px;
+ height: 6px;
+ background: #9ab895;
+ border-radius: 50%;
+}
+.lobby-grid {
+ display: grid;
+ grid-template-columns: 285px minmax(380px, 1fr) 300px;
+ min-height: 730px;
+}
+.faction-sidebar {
+ padding: 36px 28px 32px 0;
+ border-right: 1px solid var(--border);
+}
+.eyebrow {
+ font-size: 11px;
+ color: #a5a795;
+ letter-spacing: 2px;
+ font-weight: 600;
+}
+h1 {
+ font-size: 33px;
+ line-height: 1.2;
+ font-family: Georgia, serif;
+ font-weight: 400;
+ margin: 22px 0 28px;
+}
+h2 {
+ font-size: 28px;
+ font-family: Georgia, serif;
+ font-weight: 400;
+ margin: 20px 0 12px;
+}
+.faction-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.faction-choice {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ width: 100%;
+ color: var(--foreground);
+ padding: 12px 9px;
+ text-align: left;
+}
+.faction-choice:hover {
+ background: #1d231b;
+}
+.faction-choice.selected {
+ background: #222b22;
+ border-color: #485343;
+}
+.faction-choice > span:nth-child(2) {
+ flex: 1;
+}
+.faction-choice strong {
+ font-size: 15px;
+ display: block;
+ font-weight: 500;
+}
+.faction-choice small {
+ font-size: 11px;
+ color: #a8afa1;
+ display: block;
+ margin-top: 5px;
+}
+.faction-choice svg {
+ color: #6c7764;
+}
+.sigil {
+ display: grid;
+ place-items: center;
+ flex: 0 0 33px;
+ height: 36px;
+ border: 1px solid var(--faction-color);
+ color: var(--faction-color);
+ font-family: Georgia, serif;
+ font-size: 22px;
+ clip-path: polygon(0 0, 100% 0, 100% 80%, 50% 100%, 0 80%);
+}
+.planet-stage {
+ position: relative;
+ padding: 28px 30px 20px;
+ background: radial-gradient(ellipse at 50% 38%, #33332466, transparent 65%);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+.stage-top,
+.stage-bottom {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ font-size: 10px;
+ letter-spacing: 2px;
+ color: #919581;
+}
+.stage-top span:first-child {
+ color: #d0c09b;
+}
+.stage-bottom {
+ margin-top: auto;
+ padding-top: 30px;
+}
+.planet-preview {
+ position: relative;
+ flex-shrink: 0;
+ width: min(100%, 490px);
+ aspect-ratio: 1;
+ margin-top: 30px;
+ border-radius: 50%;
+ border: 1px solid #666144;
+ background:
+ repeating-conic-gradient(
+ from 0deg,
+ #82724900 0deg 19.8deg,
+ #88764d66 19.8deg 20deg
+ ),
+ radial-gradient(
+ circle,
+ #292b23 0 14%,
+ #53503a 14.1% 14.4%,
+ #373727 14.5% 34%,
+ #605538 34.1% 34.4%,
+ #343526 34.5% 57%,
+ #58543b 57.1% 57.4%,
+ #292f22 57.5%
+ );
+ box-shadow:
+ 0 0 0 14px #1a1f1780,
+ 0 0 0 15px #343a2b;
+}
+.orbital-ring {
+ position: absolute;
+ border: 1px dashed #99937142;
+ border-radius: 50%;
+}
+.ring-one {
+ inset: 8%;
+}
+.ring-two {
+ inset: 22%;
+}
+.planet-core {
+ position: absolute;
+ inset: 36%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ color: #c5bc9c;
+ background: #293127;
+ border: 1px solid #86805b;
+ border-radius: 50%;
+}
+.planet-core span {
+ font-size: 8px;
+ letter-spacing: 1px;
+}
+.map-point {
+ position: absolute;
+ text-align: center;
+ color: #e4c48a;
+ font-size: 10px;
+ letter-spacing: 1px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ flex-direction: column;
+ text-shadow: 0 2px 5px #000;
+}
+.point-0 {
+ left: 56%;
+ top: 13%;
+}
+.point-1 {
+ left: 76%;
+ top: 34%;
+}
+.point-2 {
+ left: 59%;
+ top: 77%;
+}
+.point-3 {
+ left: 12%;
+ top: 62%;
+}
+.point-4 {
+ left: 12%;
+ top: 28%;
+}
+.north-label {
+ position: absolute;
+ top: -23px;
+ left: 49%;
+ font-size: 9px;
+ color: #ccc3a1;
+}
+.storm-line {
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ background: conic-gradient(
+ from 280deg,
+ transparent 0deg,
+ #b99a4055 1deg 19deg,
+ transparent 20deg
+ );
+}
+.faction-feature {
+ width: 100%;
+ max-width: 440px;
+ margin-top: 32px;
+ border-left: 2px solid var(--faction-color);
+ padding-left: 22px;
+}
+.faction-feature h2 {
+ font-size: 32px;
+ color: var(--faction-color);
+ margin: 10px 0;
+}
+.faction-feature p {
+ font-size: 14px;
+ color: #bdc0b0;
+ line-height: 1.6;
+ margin: 0;
+}
+.feature-stats {
+ display: flex;
+ gap: 36px;
+ margin-top: 18px;
+}
+.feature-stats span {
+ font-size: 11px;
+ color: #a3aa97;
+}
+.feature-stats b {
+ display: block;
+ font-size: 23px;
+ font-weight: 400;
+ color: #e3dfd0;
+ margin-bottom: 5px;
+}
+.setup-panel {
+ padding: 36px 0 28px 28px;
+ border-left: 1px solid var(--border);
+}
+.setup-panel h2 {
+ margin-top: 24px;
+}
+.muted {
+ color: #b6bbac;
+ font-size: 14px;
+ line-height: 1.6;
+}
+.setup-panel > label {
+ display: block;
+ font-size: 13px;
+ margin: 24px 0 10px;
+}
+input[data-slot='input'] {
+ height: 44px;
+ background: #171c15;
+ border-radius: 3px;
+ font-size: 14px;
+ border-color: #454a3a;
+}
+.setting-title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 14px;
+ margin-top: 28px;
+}
+.pill {
+ font-size: 10px;
+ letter-spacing: 1px;
+ background: #303626;
+ border: 1px solid #555d41;
+ color: #c5d19b;
+ padding: 4px 8px;
+ border-radius: 3px;
+}
+.fine {
+ font-size: 11px;
+ color: #989f8d;
+ line-height: 1.6;
+}
+.expansion-list {
+ display: grid;
+ gap: 15px;
+ margin: 20px 0 30px;
+}
+.expansion {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 13px;
+ color: #c5c9bc;
+ cursor: pointer;
+}
+.expansion input {
+ accent-color: var(--primary);
+ width: 15px;
+ height: 15px;
+}
+.primary-action {
+ width: 100%;
+ min-height: 46px;
+ border-radius: 3px;
+ gap: 12px;
+ font-size: 14px;
+}
+.primary-action svg:last-child {
+ margin-left: auto;
+}
+.join-divider {
+ font-size: 10px;
+ text-align: center;
+ letter-spacing: 1.4px;
+ margin: 26px 0;
+ color: #949b85;
+}
+.join-row {
+ display: flex;
+ gap: 8px;
+}
+.join-row button {
+ height: 44px;
+ width: 48px;
+ border-radius: 3px;
+}
+.table-note {
+ border-top: 1px solid var(--border);
+ margin-top: 32px;
+ padding-top: 22px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 12px;
+ line-height: 1.8;
+ color: #a8af9c;
+}
+.development-note {
+ font-size: 11px;
+ line-height: 1.6;
+ color: #b5a477;
+ margin-top: 24px;
+}
+.notice {
+ padding: 12px;
+ border: 1px solid #807145;
+ font-size: 13px;
+ line-height: 1.6;
+ color: #efd597;
+}
+footer {
+ display: flex;
+ justify-content: space-between;
+ gap: 15px;
+ padding: 23px 0;
+ border-top: 1px solid var(--border);
+ font-size: 11px;
+ color: #919986;
+ letter-spacing: 0.4px;
+}
+@media (min-width: 1450px) {
+ .lobby-grid {
+ grid-template-columns: 300px minmax(460px, 1fr) 340px;
+ min-height: 800px;
+ }
+ .planet-preview {
+ width: 490px;
+ }
+}
+@media (max-width: 1150px) {
+ .table-shell {
+ padding: 0 24px;
+ }
+ .lobby-grid {
+ grid-template-columns: 240px minmax(310px, 1fr) 270px;
+ }
+ .planet-stage {
+ padding: 28px 20px;
+ }
+ .faction-sidebar {
+ padding-right: 16px;
+ }
+ .setup-panel {
+ padding-left: 20px;
+ }
+ .faction-choice small {
+ font-size: 10px;
+ }
+ .planet-core svg {
+ width: 55px;
+ }
+ .stage-top span:last-child {
+ display: none;
+ }
+}
+@media (max-width: 950px) {
+ .lobby-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+ .planet-stage {
+ grid-column: 1/-1;
+ grid-row: 1;
+ min-height: 500px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 28px;
+ }
+ .stage-top,
+ .stage-bottom {
+ grid-column: 1/-1;
+ }
+ .planet-preview {
+ margin: 15px 0;
+ }
+ .faction-feature {
+ margin: 0;
+ }
+ .faction-sidebar {
+ border-top: 1px solid var(--border);
+ }
+ .setup-panel {
+ border-top: 1px solid var(--border);
+ border-left: 0;
+ padding-left: 28px;
+ }
+ .faction-choice {
+ max-width: 330px;
+ }
+}
+@media (max-width: 600px) {
+ .table-shell {
+ padding: 0 18px;
+ }
+ .masthead {
+ min-height: 88px;
+ }
+ .wordmark {
+ font-size: 32px;
+ }
+ .wordmark span {
+ font-size: 8px;
+ }
+ .masthead-right {
+ font-size: 0;
+ gap: 0;
+ }
+ .masthead-right a {
+ font-size: 12px;
+ margin: 0;
+ }
+ .status-dot {
+ display: none;
+ }
+ .planet-stage {
+ display: flex;
+ padding: 24px 12px;
+ gap: 12px;
+ min-height: 0;
+ }
+ .planet-preview {
+ width: 78%;
+ margin: 22px 0;
+ }
+ .faction-feature {
+ margin-top: 20px;
+ }
+ .faction-feature h2 {
+ font-size: 26px;
+ }
+ .stage-bottom {
+ display: none;
+ }
+ .lobby-grid {
+ display: flex;
+ flex-direction: column;
+ }
+ .faction-sidebar {
+ order: 1;
+ border-right: 0;
+ padding: 25px 0;
+ }
+ .faction-sidebar h1 {
+ font-size: 28px;
+ margin: 12px 0 22px;
+ }
+ .faction-sidebar h1 br {
+ display: none;
+ }
+ .faction-list {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+ }
+ .faction-choice {
+ padding: 10px 6px;
+ gap: 9px;
+ }
+ .faction-choice strong {
+ font-size: 13px;
+ }
+ .faction-choice small,
+ .faction-choice svg {
+ display: none;
+ }
+ .setup-panel {
+ order: 2;
+ padding: 26px 0;
+ }
+ .planet-stage {
+ order: 0;
+ }
+ .feature-stats {
+ gap: 28px;
+ }
+ footer {
+ font-size: 10px;
+ flex-direction: column;
+ }
+}
+/* Shared play surface */
+.wordmark-button {
+ background: none;
+ border: 0;
+ text-align: left;
+ color: inherit;
+}
+.game-banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 24px 0;
+ font-size: 14px;
+}
+.phase-track {
+ display: flex;
+ gap: 6px;
+ overflow: auto;
+ padding-bottom: 20px;
+}
+.phase-track > span {
+ flex: 1;
+ display: flex;
+ gap: 7px;
+ align-items: center;
+ white-space: nowrap;
+ font-size: 11px;
+ color: #7e8a77;
+ border-bottom: 2px solid #303c2b;
+ padding: 8px 5px 12px;
+}
+.phase-track b {
+ font-weight: 400;
+}
+.phase-track .current {
+ color: #f0cf86;
+ border-color: #d7b16c;
+}
+.phase-track .done {
+ color: #b8c5a8;
+}
+.play-grid {
+ display: grid;
+ grid-template-columns: 210px minmax(450px, 1fr) 280px;
+ gap: 24px;
+}
+.players-panel {
+ border-right: 1px solid var(--border);
+ padding-right: 22px;
+}
+.player-card {
+ padding: 20px 0;
+ border-bottom: 1px solid var(--border);
+}
+.player-heading {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.player-heading strong {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--faction-color);
+}
+.player-heading small {
+ font-size: 12px;
+ display: block;
+ color: #a4ac99;
+ margin-top: 4px;
+}
+.player-heading span:nth-child(2) {
+ flex: 1;
+}
+.player-stats {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 11px;
+ color: #9faa91;
+ margin-top: 14px;
+}
+.active-player {
+ background: linear-gradient(90deg, #d1b26313, transparent);
+ border-left: 2px solid var(--faction-color);
+ padding-left: 10px;
+}
+.resource-readout {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: #e0bf78;
+ margin: 26px 0 14px;
+}
+.resource-readout strong {
+ font-size: 30px;
+ font-weight: 400;
+}
+.resource-readout span {
+ font-size: 12px;
+ color: #adb39e;
+}
+.board-column {
+ min-width: 0;
+}
+.board-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12px;
+ color: #b3baa5;
+}
+.board-toolbar > span {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.board-viewport {
+ overflow: auto;
+ aspect-ratio: 1;
+ scrollbar-width: thin;
+ border-radius: 50%;
+ background: #272d2050;
+ box-shadow: 0 0 45px #0006;
+}
+.game-board {
+ display: block;
+ min-width: 100%;
+ height: auto;
+}
+.territory {
+ cursor: pointer;
+ outline: none;
+}
+.territory polygon {
+ fill: #575f3530;
+ stroke: #ac9f6777;
+ stroke-width: 1.5;
+ transition:
+ fill 0.2s,
+ stroke 0.2s;
+}
+.territory.rock polygon {
+ fill: #272f26b0;
+ stroke: #b6a879;
+}
+.territory.stronghold polygon {
+ fill: #756638c0;
+ stroke: #ebcd84;
+ stroke-width: 3;
+}
+.territory.polar polygon {
+ fill: #a4a58280;
+}
+.territory.selected polygon,
+.territory:hover polygon,
+.territory:focus polygon {
+ fill: #c8ad5b80;
+ stroke: #ffdfa1;
+ stroke-width: 3;
+}
+.territory text {
+ fill: #eee4c5;
+ font:
+ 13px Arial,
+ sans-serif;
+ pointer-events: none;
+ paint-order: stroke;
+ stroke: #242c21;
+ stroke-width: 2px;
+ stroke-linejoin: round;
+}
+.sector-label {
+ font:
+ 14px Arial,
+ sans-serif;
+ fill: #dbcc9a;
+ text-anchor: middle;
+ pointer-events: none;
+}
+.force-token circle {
+ stroke: #111910;
+ stroke-width: 2;
+ filter: drop-shadow(0 3px 2px #0006);
+}
+.force-token text,
+.spice-amount {
+ font:
+ bold 13px Arial,
+ sans-serif;
+ fill: #161c13;
+}
+.storm-overlay {
+ transition: transform 1.2s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+.territory-detail {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 24px;
+ padding-top: 20px;
+ border-top: 1px solid var(--border);
+}
+.territory-detail h3 {
+ font:
+ 24px Georgia,
+ serif;
+ margin: 8px 0;
+}
+.territory-units {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ font-size: 13px;
+ min-height: 24px;
+ margin-top: 12px;
+}
+.action-panel {
+ border-left: 1px solid var(--border);
+ padding-left: 24px;
+ padding-bottom: 24px;
+}
+.action-panel h2 {
+ font-size: 26px;
+}
+.action-panel label {
+ font-size: 13px;
+ display: block;
+ margin: 16px 0 8px;
+}
+.action-panel label input,
+.action-panel select,
+.action-panel textarea {
+ margin-top: 8px;
+}
+.action-panel select,
+.action-panel textarea {
+ width: 100%;
+ background: #1b2118;
+ color: #e5e4d3;
+ border: 1px solid #49513e;
+ border-radius: 3px;
+ padding: 10px 8px;
+ font-size: 13px;
+ min-height: 42px;
+}
+.game-action {
+ width: 100%;
+ min-height: 40px;
+ height: auto;
+ white-space: normal;
+ margin: 7px 0;
+ border-radius: 3px;
+ font-size: 13px;
+ padding: 10px 12px;
+ justify-content: space-between;
+ gap: 8px;
+}
+.game-action + .game-action {
+ background: #283121;
+ color: #d4dabe;
+ border: 1px solid #485139;
+}
+.auction-card {
+ padding: 20px;
+ border: 1px solid #6d6847;
+ background: #393b2333;
+ margin: 20px 0;
+}
+.auction-card h3 {
+ font:
+ 24px Georgia,
+ serif;
+ margin: 20px 0;
+}
+.auction-card strong {
+ font-size: 34px;
+ color: #d6b973;
+ font-weight: 400;
+}
+.auction-card small {
+ font-size: 13px;
+ color: #b2baa1;
+}
+.battle-location {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 15px;
+ color: #e0ba75;
+}
+.revealed-plans > div {
+ border: 1px solid #70633f;
+ padding: 14px;
+ margin: 10px 0;
+ font-size: 14px;
+}
+.revealed-plans span {
+ display: block;
+ font-size: 12px;
+ color: #b5bca6;
+ margin-top: 8px;
+}
+.prescience {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+.prescience button {
+ font-size: 11px;
+}
+.player-console {
+ margin-top: 28px;
+ border-top: 1px solid var(--border);
+}
+.console-tabs {
+ display: flex;
+ gap: 28px;
+ border-bottom: 1px solid var(--border);
+}
+.console-tabs button {
+ background: none;
+ border: 0;
+ color: #9ca68d;
+ font-size: 14px;
+ padding: 18px 0;
+ border-bottom: 2px solid transparent;
+}
+.console-tabs button.selected {
+ color: #e0c384;
+ border-color: #e0c384;
+}
+.hand-grid {
+ display: flex;
+ gap: 16px;
+ overflow: auto;
+ padding: 24px 0;
+}
+.treachery-card {
+ flex: 0 0 240px;
+ min-height: 210px;
+ background: linear-gradient(145deg, #343a2b, #171e15);
+ border: 1px solid #626243;
+ border-radius: 6px;
+ padding: 18px 14px;
+ position: relative;
+ overflow: hidden;
+ transition:
+ transform 0.2s,
+ border-color 0.2s;
+}
+.treachery-card:hover {
+ transform: translateY(-5px);
+ border-color: #dbc57e;
+}
+.treachery-card h3 {
+ font:
+ 21px/1.2 Georgia,
+ serif;
+ margin: 16px 0;
+ color: #e7d6a7;
+}
+.card-mark {
+ display: block;
+ font:
+ 50px Georgia,
+ serif;
+ text-align: center;
+ color: #bca264;
+ margin: 8px 0;
+}
+.treachery-card button {
+ font-size: 12px;
+ width: 100%;
+ margin-top: 10px;
+}
+.traitor-list {
+ display: flex;
+ gap: 20px;
+ align-items: center;
+ flex-wrap: wrap;
+ font-size: 14px;
+ color: #d7b58b;
+ padding: 8px 0 24px;
+}
+.game-log {
+ list-style: none;
+ margin: 0;
+ padding: 20px 0;
+ max-height: 380px;
+ overflow: auto;
+}
+.game-log li {
+ padding: 12px 0;
+ border-bottom: 1px solid #293323;
+ font-size: 14px;
+ line-height: 1.6;
+ display: flex;
+ gap: 24px;
+}
+.game-log li > span {
+ font: 12px monospace;
+ color: #83916e;
+}
+.coverage-copy {
+ max-width: 800px;
+ padding: 16px 0 28px;
+ font-size: 15px;
+ line-height: 1.7;
+ color: #bbc3ac;
+}
+.coverage-copy h3 {
+ font:
+ 24px Georgia,
+ serif;
+ color: #e4d5ad;
+}
+.coverage-copy a {
+ color: #e4c782;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.floating-notice {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ max-width: calc(100vw - 32px);
+ padding: 16px 20px;
+ background: #3a3424;
+ border: 1px solid #baa469;
+ box-shadow: 0 12px 50px #0008;
+ border-radius: 6px;
+ font-size: 14px;
+ display: flex;
+ gap: 24px;
+ z-index: 30;
+ line-height: 1.6;
+}
+.floating-notice button {
+ background: none;
+ border: 0;
+ color: inherit;
+ font-size: 22px;
+}
+.rule-page {
+ max-width: 1000px;
+ margin: auto;
+ padding: 40px 24px;
+ line-height: 1.7;
+}
+.rule-page a {
+ color: #e1c37b;
+}
+.rule-page table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 14px;
+}
+.rule-page th,
+.rule-page td {
+ padding: 14px 12px;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ vertical-align: top;
+}
+@media (max-width: 1150px) {
+ .play-grid {
+ grid-template-columns: 170px minmax(330px, 1fr) 245px;
+ gap: 16px;
+ }
+ .action-panel {
+ padding-left: 16px;
+ }
+ .players-panel {
+ padding-right: 12px;
+ }
+ .phase-track {
+ gap: 5px;
+ }
+ .phase-track > span {
+ font-size: 10px;
+ }
+ .player-heading {
+ gap: 8px;
+ }
+ .player-heading strong {
+ font-size: 12px;
+ }
+}
+@media (max-width: 950px) {
+ .play-grid {
+ grid-template-columns: 1fr 260px;
+ }
+ .players-panel {
+ grid-column: 1/-1;
+ display: flex;
+ overflow: auto;
+ gap: 16px;
+ border: 0;
+ padding: 0;
+ }
+ .players-panel > .eyebrow,
+ .players-panel > .fine {
+ display: none;
+ }
+ .player-card {
+ min-width: 150px;
+ border: 1px solid var(--border);
+ padding: 14px;
+ }
+ .resource-readout {
+ margin: 0;
+ min-width: 120px;
+ }
+ .phase-track > span {
+ min-width: 110px;
+ }
+ .board-viewport {
+ margin-top: 12px;
+ }
+}
+@media (max-width: 600px) {
+ .play-grid {
+ display: flex;
+ flex-direction: column;
+ }
+ .board-column {
+ order: 1;
+ }
+ .players-panel {
+ order: 0;
+ }
+ .action-panel {
+ order: 2;
+ padding: 24px 0;
+ border-left: 0;
+ border-top: 1px solid var(--border);
+ }
+ .live-table .masthead {
+ flex-wrap: wrap;
+ gap: 12px;
+ padding: 16px 0;
+ }
+ .live-table .masthead-right {
+ width: 100%;
+ max-width: 100%;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 8px;
+ font-size: 0;
+ }
+ .live-table .masthead-right .status-dot {
+ display: none;
+ }
+ .live-table .masthead-right button {
+ font-size: 12px;
+ }
+ .game-banner {
+ font-size: 12px;
+ align-items: start;
+ }
+ .game-banner .eyebrow {
+ font-size: 10px;
+ }
+ .phase-track > span {
+ min-width: 95px;
+ font-size: 10px;
+ }
+ .player-card {
+ min-width: 140px;
+ }
+ .player-console {
+ margin-top: 10px;
+ }
+ .console-tabs {
+ gap: 20px;
+ }
+ .console-tabs button {
+ font-size: 12px;
+ }
+ .treachery-card {
+ flex-basis: min(78vw, 240px);
+ }
+ .rule-page table {
+ font-size: 12px;
+ }
+ .rule-page th,
+ .rule-page td {
+ padding: 10px 6px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation: none !important;
+ transition: none !important;
+ scroll-behavior: auto !important;
+ }
+}
+/* Original environmental artwork; motion stays out of the decision controls. */
+.table-shell:not(.live-table) .planet-stage {
+ background-image:
+ linear-gradient(180deg, #101811a8, #101811c2 55%, #101311 95%),
+ url('/art/arrakis-dusk.png');
+ background-size: cover;
+ background-position: 48% 50%;
+}
+.planet-preview {
+ background-color: #242e21;
+ background-image:
+ repeating-conic-gradient(
+ from 0deg,
+ #82724900 0deg 19.8deg,
+ #cab47655 19.8deg 20deg
+ ),
+ linear-gradient(#334029aa, #1c271bcc), url('/art/arrakis-terrain.png');
+ background-size: cover;
+}
+.lobby-grid,
+.play-grid {
+ animation: table-enter 0.55s ease-out both;
+}
+.force-token {
+ animation: force-arrive 0.35s ease-out;
+}
+.treachery-card {
+ animation: card-arrive 0.35s ease-out both;
+}
+.status-dot {
+ box-shadow: 0 0 8px #9ab89577;
+}
+.current b {
+ color: #ffda86;
+}
+.planet-stage .faction-feature {
+ background: linear-gradient(90deg, #101811a8, transparent);
+ padding-top: 10px;
+ padding-bottom: 10px;
+}
+.planet-stage .faction-feature .eyebrow {
+ color: #b9c1a8;
+}
+@keyframes table-enter {
+ from {
+ opacity: 0;
+ translate: 0 8px;
+ }
+ to {
+ opacity: 1;
+ translate: 0 0;
+ }
+}
+@keyframes force-arrive {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+@keyframes card-arrive {
+ from {
+ opacity: 0;
+ translate: 0 12px;
+ }
+ to {
+ opacity: 1;
+ translate: 0 0;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .lobby-grid,
+ .play-grid,
+ .force-token,
+ .treachery-card {
+ animation: none;
+ }
+}
+
+.action-panel .decision-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ margin: 0.8rem 0;
+ line-height: 1.5;
+}
+.action-panel .decision-checkbox input {
+ width: 1rem;
+ height: 1rem;
+ flex: 0 0 auto;
+ accent-color: var(--primary);
+ margin: 0;
+}
+.alliance-funding {
+ margin: 1rem 0;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0.85rem;
+}
+.alliance-funding summary {
+ cursor: pointer;
+ color: var(--primary);
+ font-size: 0.8rem;
+ line-height: 1.5;
+}
+.alliance-funding[open] summary {
+ margin-bottom: 0.8rem;
+}
+
+.help-tip {
+ display: inline-flex;
+ vertical-align: middle;
+ color: var(--primary);
+ align-items: center;
+ justify-content: center;
+ padding: 5px;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ cursor: help;
+}
+.help-tip:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+.game-help-tooltip {
+ background: #23281f;
+ color: #eeeae0;
+ border: 1px solid #6b634b;
+ max-width: min(290px, calc(100vw - 24px));
+ padding: 12px 14px;
+ line-height: 1.6;
+}
+.phase-guidance {
+ margin: 0 0 18px;
+ font-size: 12px;
+ color: #b6bfa9;
+}
+.phase-guidance summary {
+ cursor: pointer;
+ color: var(--primary);
+}
+.phase-guidance p {
+ margin: 10px 0;
+ line-height: 1.6;
+}
+.reference-page {
+ max-width: 1280px;
+ margin: auto;
+ padding: 48px 5vw 90px;
+}
+.reference-header {
+ max-width: 830px;
+ padding-bottom: 38px;
+}
+.reference-header > a {
+ font-size: 12px;
+ color: #b6bfa9;
+}
+.reference-header .eyebrow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 42px;
+}
+.reference-header h1 {
+ font-size: clamp(36px, 5vw, 68px);
+ line-height: 1.08;
+ margin: 20px 0;
+}
+.reference-header h1 em {
+ color: var(--primary);
+ font-weight: normal;
+}
+.reference-header p {
+ max-width: 650px;
+ line-height: 1.7;
+ color: #b6bfa9;
+}
+.reference-header .reference-status {
+ font-size: 12px;
+}
+.reference-status a {
+ color: var(--primary);
+ display: inline;
+}
+.reference-status svg {
+ display: inline;
+}
+.reference-layout {
+ display: grid;
+ grid-template-columns: 245px minmax(0, 1fr);
+ gap: 48px;
+ align-items: start;
+}
+.reference-nav,
+.reference-results {
+ min-width: 0;
+}
+.reference-nav {
+ position: sticky;
+ top: 24px;
+}
+.reference-nav label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ margin-bottom: 10px;
+}
+.reference-nav nav {
+ display: grid;
+ gap: 4px;
+ margin-top: 20px;
+}
+.reference-nav nav button {
+ text-align: left;
+ display: flex;
+ justify-content: space-between;
+ padding: 12px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ color: #b6bfa9;
+ background: transparent;
+ cursor: pointer;
+ font-size: 12px;
+}
+.reference-nav nav button[aria-pressed='true'] {
+ border-color: #5d644a;
+ background: #283022;
+ color: #ead9ae;
+}
+.reference-nav nav button span {
+ opacity: 0.6;
+}
+.source-links {
+ margin-top: 28px;
+ font-size: 12px;
+}
+.source-links summary {
+ cursor: pointer;
+ color: var(--primary);
+}
+.source-links a {
+ display: block;
+ padding-top: 12px;
+ color: #b6bfa9;
+}
+.rule-topic {
+ scroll-margin-top: 28px;
+ border-top: 1px solid var(--border);
+ padding: 22px 0;
+}
+.rule-topic:target {
+ border-color: var(--primary);
+ background: #c9a86908;
+}
+.rule-topic summary {
+ cursor: pointer;
+ list-style: none;
+ position: relative;
+ padding-right: 85px;
+}
+.rule-topic summary::after {
+ content: '+';
+ position: absolute;
+ right: 2px;
+ top: 1px;
+ color: var(--primary);
+ font-size: 20px;
+}
+.rule-topic details[open] > summary::after {
+ content: '−';
+}
+.rule-topic h2 {
+ font-size: 23px;
+ line-height: 1.3;
+ margin: 9px 0;
+}
+.rule-topic summary p {
+ color: #b6bfa9;
+ font-size: 14px;
+ line-height: 1.6;
+ margin-bottom: 0;
+}
+.coverage-label {
+ display: inline-block;
+ font-family: sans-serif;
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ border: 1px solid #4e5546;
+ color: #b6bfa9;
+ border-radius: 4px;
+ padding: 3px 6px;
+}
+.coverage-implemented {
+ color: #89c69e;
+ border-color: #436650;
+}
+.coverage-partial {
+ color: #d7b97b;
+ border-color: #766239;
+}
+.rule-topic-content {
+ color: #c5cab9;
+ font-size: 14px;
+ line-height: 1.8;
+ padding-top: 16px;
+}
+.rule-topic-content ol {
+ padding-left: 22px;
+}
+.rule-topic-content li {
+ padding: 5px 0 5px 5px;
+}
+.rule-example {
+ padding: 15px 20px;
+ background: #c9a8690b;
+ border-left: 2px solid var(--primary);
+ margin: 22px 0;
+}
+.rule-example strong {
+ color: var(--primary);
+ font-size: 10px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+.rule-example p {
+ margin: 6px 0 0;
+}
+.related-rules {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 20px;
+}
+.related-rules a {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ border: 1px solid var(--border);
+ padding: 4px 9px;
+ border-radius: 4px;
+ color: var(--primary);
+ font-size: 11px;
+}
+.rule-permalink {
+ display: inline-block;
+ margin-top: 18px;
+ font-size: 10px;
+ color: #a1ab95;
+}
+@media (max-width: 800px) {
+ .reference-layout {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 28px;
+ }
+ .reference-nav {
+ position: static;
+ }
+ .reference-nav nav {
+ display: flex;
+ overflow-x: auto;
+ padding-bottom: 8px;
+ }
+ .reference-nav nav button {
+ white-space: nowrap;
+ gap: 14px;
+ }
+ .rule-topic summary {
+ padding-right: 25px;
+ }
+ .reference-header .eyebrow {
+ margin-top: 28px;
+ }
+}
+
+.bot-controls {
+ margin: 20px 0;
+ padding: 16px;
+ border: 1px solid var(--line, #50432d);
+ border-radius: 8px;
+}
+.bot-controls h3 {
+ font-size: 15px;
+ margin-bottom: 12px;
+}
+.bot-controls label {
+ display: grid;
+ gap: 5px;
+ margin: 10px 0;
+ font-size: 12px;
+}
+.bot-controls select {
+ width: 100%;
+ background: #18201d;
+ color: #eee4ce;
+ border: 1px solid #50432d;
+ border-radius: 5px;
+ padding: 8px;
+}
+
+.private-battle-plan {
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--gold, #d8b66a);
+ border-radius: 12px;
+ background: rgba(188, 151, 76, 0.08);
+}
+.private-battle-plan h3 {
+ margin: 0.5rem 0 1rem;
+}
+.private-battle-plan dl {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.85rem;
+ margin: 0;
+}
+.private-battle-plan dt {
+ opacity: 0.65;
+ font-size: 0.75rem;
+}
+.private-battle-plan dd {
+ margin: 0.2rem 0 0;
+ overflow-wrap: anywhere;
+}
+
+.truthtrance-panel fieldset {
+ min-width: 0;
+ margin: 16px 0;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 3px;
+}
+.truthtrance-panel textarea {
+ resize: vertical;
+ line-height: 1.6;
+}
+.truthtrance-panel blockquote {
+ margin: 18px 0;
+ padding: 2px 14px;
+ border-left: 2px solid var(--primary);
+}
+.truthtrance-panel,
+.truthtrance-history {
+ overflow-wrap: anywhere;
+}
+.truthtrance-panel :is(textarea, select):focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+
+.room-entry-panel {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ max-width: 46rem;
+ margin: 1.5rem auto;
+ padding: clamp(1rem, 4vw, 2rem);
+ font-size: 1rem;
+ line-height: 1.65;
+ overflow-wrap: anywhere;
+}
+.room-entry-panel > :is(h1, p, output) {
+ margin: 0;
+}
+.room-entry-panel button {
+ min-height: 44px;
+ white-space: normal;
+ text-align: left;
+}
+.room-entry-panel > label {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.65rem;
+}
+.room-entry-panel input[type='checkbox'] {
+ flex: 0 0 auto;
+ width: 20px;
+ height: 20px;
+ margin-top: 0.2rem;
+}
+.room-entry-panel > details {
+ width: 100%;
+ min-width: 0;
+}
+
+.traitor-identity {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+}
+.traitor-identity > span {
+ overflow-wrap: anywhere;
+}
+.leader-roster {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
+ gap: 1rem;
+ margin: 1rem 0;
+}
+.leader-identity {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1.25rem;
+ border: 1px solid var(--border);
+ border-radius: 0.75rem;
+ background: linear-gradient(145deg, #293326, #141914);
+ text-align: center;
+ overflow-wrap: anywhere;
+}
+.leader-identity :is(h3, p) {
+ margin: 0;
+}
+.leader-identity button {
+ margin-top: auto;
+ min-height: 44px;
+}
+.leader-strength-disc {
+ display: grid;
+ place-items: center;
+ width: 5rem;
+ height: 5rem;
+ border: 2px solid #b69a62;
+ border-radius: 50%;
+ outline: 1px solid #685b3f;
+ outline-offset: -7px;
+ background: radial-gradient(circle at 35% 30%, #4a5037, #151c15);
+ color: #f5dfb0;
+ font-family: Georgia, serif;
+ font-size: 2rem;
+}
+
+/* Authorized component faces, separate from the action that commits them. */
+.setup-traitor-choices,
+.visible-component-list {
+ display: grid;
+ gap: 0.75rem;
+ min-width: 0;
+ padding: 0;
+ list-style: none;
+}
+.traitor-choice,
+.visible-component-list > div,
+.visible-component-list > li {
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+ min-width: 0;
+ padding: 0.85rem;
+ border: 1px solid var(--border);
+ border-radius: 0.6rem;
+ overflow-wrap: anywhere;
+}
+.traitor-choice .game-action {
+ margin: 0;
+}
+.battle-component-inspection {
+ margin-top: 0.75rem;
+ min-width: 0;
+}
+.battle-component-inspection > summary {
+ display: list-item;
+ cursor: pointer;
+ min-height: 44px;
+ padding: 0.75rem 0;
+ color: var(--primary);
+}
+.battle-component-inspection > summary:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 3px;
+}
+
+.player-circle-choice {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+ font-size: 0.875rem;
+}
+.player-circle-choice select {
+ min-height: 44px;
+}
+.board-player-circle {
+ pointer-events: none;
+}
+
+.card-unavailable {
+ margin: 0.7rem 0;
+ padding: 0.7rem;
+ border-left: 2px solid #bda369;
+ background: #151912;
+ color: #d4c7a5;
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+.private-spice-preview {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 1rem;
+ padding: 1rem 1.25rem;
+ margin: 1rem 0;
+ border: 1px solid var(--border);
+ border-radius: 0.6rem;
+ background: #262b20;
+}
+.private-spice-preview > div {
+ flex: 1 1 16rem;
+}
+.private-spice-preview h3 {
+ margin: 0.4rem 0;
+ font-family: Georgia, serif;
+}
+.private-spice-preview > button {
+ flex: 0 1 17rem;
+}
+.spice-card-table {
+ margin-top: 1.5rem;
+ border-top: 1px solid var(--border);
+ padding-top: 1rem;
+}
+.spice-card-table > h3 {
+ margin: 0 0 0.8rem;
+ font-family: Georgia, serif;
+ font-weight: 400;
+}
+.spice-card-fronts {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
+ gap: 0.8rem;
+}
+.spice-card-front {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+ min-width: 0;
+ padding: 1rem;
+ border: 1px solid #71613f;
+ border-radius: 0.6rem;
+ background: linear-gradient(135deg, #393524, #1b2019);
+ overflow-wrap: anywhere;
+}
+.spice-card-front > strong {
+ font-family: Georgia, serif;
+ font-size: 1.15rem;
+ font-weight: 400;
+}
+.spice-card-front > button {
+ margin-top: auto;
+}
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 00000000..13312422
--- /dev/null
+++ b/app/layout.tsx
@@ -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 (
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 00000000..e1b1f9bd
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,1049 @@
+'use client';
+import Link from 'next/link';
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { GameTable } from '@/components/game-table';
+import {
+ SeatRecoverySetup,
+ SeatRecoveryClaim,
+} from '@/components/seat-recovery';
+import type { GameView, Action } from '@/game/engine';
+import {
+ ArrowRight,
+ Users,
+ BookOpen,
+ Globe,
+ ChevronRight,
+ Shield,
+ Orbit,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { FACTIONS, EXPANSIONS, faction } from '@/game/catalog';
+import {
+ ClientRequestError,
+ requestJson,
+ requestMayHaveCompleted,
+} from '@/lib/client-request';
+
+import {
+ ROOM_ENTRY_STORAGE_KEY,
+ clearRoomEntry,
+ createRoomEntry,
+ parseRoomEntry,
+ roomEntryConfirmed,
+ roomEntryRejectedBeforeCommit,
+ roomEntryUrl,
+ saveRoomEntry,
+ type RoomEntryAttempt,
+} from '@/lib/room-entry';
+
+// A rejected invitation needs the ordinary join form, not network recovery.
+const inviteRejected = (error: unknown) =>
+ error instanceof ClientRequestError &&
+ error.kind === 'http' &&
+ [401, 403, 404, 409].includes(error.status ?? 0);
+export default function Home() {
+ const activeRoom = useRef(null);
+ const activeSeat = useRef(null);
+ const mutationPending = useRef(false);
+ const reconnectPending = useRef(false);
+ const [checkingConnection, setCheckingConnection] = useState(false);
+ const reads = useRef(new Map>());
+ const knownVersions = useRef(new Map());
+ const recovery = useRef<{ room: string; epoch: number } | null>(null);
+ const epoch = useRef(0);
+ const botAttempt = useRef<{ room: string; version: number } | null>(null);
+ const [restoring, setRestoring] = useState(true);
+ const [restoreFailed, setRestoreFailed] = useState(false);
+ const [needsReconcile, setNeedsReconcile] = useState(false);
+ const [botRecovery, setBotRecovery] = useState(false);
+ const [game, setGame] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [connection, setConnection] = useState('');
+ const [selected, setSelected] = useState('atreides');
+ const [expansions, setExpansions] = useState([]);
+ const [name, setName] = useState('');
+ const [code, setCode] = useState('');
+ const [notice, setNotice] = useState('');
+ const seatClaimUncertain = useRef(false);
+ const [claimUncertain, setClaimUncertain] = useState(false);
+ const pendingEntry = useRef(null);
+ const [entryAttempt, setEntryAttempt] = useState(
+ null,
+ );
+ const [entryProblem, setEntryProblem] = useState('');
+ const [showEntryRecovery, setShowEntryRecovery] = useState(false);
+ const [abandonAcknowledged, setAbandonAcknowledged] = useState(false);
+ const f = faction(selected);
+ const roomCode = game?.code;
+ // This function is deliberately GET-only, including WebMCP reads and recovery.
+ const refresh = useCallback(
+ async (room: string, fresh = false): Promise => {
+ let pending = reads.current.get(room);
+ if (pending && !fresh) return pending;
+ // Reconciliation must start after previously issued reads have settled.
+ while (pending) {
+ await pending.catch(() => {});
+ pending = reads.current.get(room);
+ }
+ const readEpoch = epoch.current;
+ const request = requestJson(`/api/rooms/${room}`, {
+ cache: 'no-store',
+ })
+ .then((data) => {
+ if (
+ activeRoom.current !== room ||
+ readEpoch !== epoch.current ||
+ data.version < (knownVersions.current.get(room) ?? -1)
+ )
+ throw new ClientRequestError(
+ 'This read belongs to an older table or seat. Read the current table again.',
+ 'aborted',
+ );
+ activeSeat.current = data.me;
+ knownVersions.current.set(room, data.version);
+ setGame((previous) =>
+ !previous ||
+ previous.code !== data.code ||
+ data.version >= previous.version
+ ? data
+ : previous,
+ );
+ setConnection('');
+ if (
+ recovery.current?.room === room &&
+ recovery.current.epoch === readEpoch
+ ) {
+ recovery.current = null;
+ setNeedsReconcile(false);
+ }
+ if (
+ botAttempt.current?.room === room &&
+ ((!data.botsPending &&
+ data.version >= botAttempt.current.version) ||
+ data.version > botAttempt.current.version)
+ ) {
+ botAttempt.current = null;
+ setBotRecovery(false);
+ }
+ return data;
+ })
+ .finally(() => {
+ reads.current.delete(room);
+ });
+ reads.current.set(room, request);
+ return request;
+ },
+ [],
+ );
+
+ const reconcile = useCallback(
+ async (room: string) => {
+ recovery.current = { room, epoch: ++epoch.current };
+ setNeedsReconcile(true);
+ try {
+ await refresh(room, true);
+ } catch {
+ if (activeRoom.current === room)
+ setConnection(
+ 'Reconnect to confirm the latest table before taking another action.',
+ );
+ }
+ },
+ [refresh],
+ );
+
+ const advanceBots = useCallback(
+ async (data: GameView, explicit = false) => {
+ const room = data.code;
+ if (
+ !data.botsPending ||
+ activeRoom.current !== room ||
+ data.me !== activeSeat.current ||
+ data.version < (knownVersions.current.get(room) ?? -1) ||
+ mutationPending.current ||
+ recovery.current
+ )
+ return;
+ if (
+ !explicit &&
+ botAttempt.current?.room === room &&
+ botAttempt.current.version === data.version
+ )
+ return;
+ const seat = activeSeat.current,
+ requestEpoch = epoch.current;
+ mutationPending.current = true;
+ setBusy(true);
+ botAttempt.current = { room, version: data.version };
+ try {
+ const next = await requestJson(`/api/rooms/${room}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ version: data.version,
+ action: { type: 'advanceBots' },
+ }),
+ });
+ if (
+ activeRoom.current === room &&
+ activeSeat.current === seat &&
+ epoch.current === requestEpoch
+ ) {
+ knownVersions.current.set(
+ room,
+ Math.max(next.version, knownVersions.current.get(room) ?? -1),
+ );
+ setGame((previous) =>
+ !previous ||
+ previous.code !== room ||
+ next.version >= previous.version
+ ? next
+ : previous,
+ );
+ botAttempt.current = null;
+ setBotRecovery(false);
+ }
+ } catch (error) {
+ if (
+ activeRoom.current === room &&
+ activeSeat.current === seat &&
+ epoch.current === requestEpoch
+ ) {
+ // A confirmed rejection may be retried from a new read. An uncertain POST is never replayed.
+ const rejected =
+ error instanceof ClientRequestError &&
+ error.kind === 'http' &&
+ error.status === 409;
+ if (rejected) botAttempt.current = null;
+ else setBotRecovery(true);
+ await reconcile(room);
+ }
+ } finally {
+ mutationPending.current = false;
+ setBusy(false);
+ }
+ },
+ [reconcile],
+ );
+
+ useEffect(() => {
+ // A saved entry always gets an explicit retry screen. A GET with another
+ // seat's cookie cannot prove which create/join operation completed.
+ try {
+ const saved = window.sessionStorage.getItem(ROOM_ENTRY_STORAGE_KEY);
+ if (saved !== null) {
+ const attempt = parseRoomEntry(saved);
+ pendingEntry.current = attempt;
+ // oxlint-disable-next-line react/react-compiler -- Hydrate an external tab-scoped recovery record after SSR.
+ setEntryAttempt(attempt);
+ setRestoring(false);
+ return;
+ }
+ } catch {
+ setEntryProblem(
+ 'The saved room request could not be read safely. It has not been sent or deleted. Restore access to this tab’s storage and reload, or deliberately abandon it below.',
+ );
+ setRestoring(false);
+ return;
+ }
+ const room = new URLSearchParams(window.location.search).get('room');
+ let live = true;
+ if (room && /^[A-Z2-9]{8}$/.test(room)) {
+ activeRoom.current = room;
+ // oxlint-disable-next-line react/react-compiler -- Restore server-owned state from the external invite URL; refresh awaits the network.
+ void refresh(room)
+ .catch((error: Error) => {
+ if (live && activeRoom.current === room) {
+ setCode(room);
+ setNotice(error.message);
+ if (inviteRejected(error)) activeRoom.current = null;
+ else setRestoreFailed(true);
+ }
+ })
+ .finally(() => {
+ if (live) setRestoring(false);
+ });
+ } else {
+ // oxlint-disable-next-line react/react-compiler -- The SSR-safe restoration placeholder resolves after checking the browser's invite URL.
+ setRestoring(false);
+ }
+ return () => {
+ live = false;
+ };
+ }, [refresh]);
+
+ useEffect(() => {
+ if (!roomCode) return;
+ let live = true;
+ let timer: ReturnType;
+ const tick = async () => {
+ let nextPollMs = 2000;
+ try {
+ if (
+ live &&
+ document.visibilityState === 'visible' &&
+ !mutationPending.current
+ ) {
+ const data = await refresh(roomCode);
+ // Authenticated reads resume the persisted queue. Poll often enough to
+ // display each paced action without posting redundant wake-up mutations.
+ nextPollMs = data.botsPending ? 500 : 2000;
+ }
+ } catch {
+ if (live && activeRoom.current === roomCode)
+ setConnection('Connection interrupted. Retrying…');
+ } finally {
+ // Schedule after completion, so slow requests cannot stack polling cycles.
+ if (live) timer = setTimeout(tick, nextPollMs);
+ }
+ };
+ timer = setTimeout(tick, 2000);
+ return () => {
+ live = false;
+ clearTimeout(timer);
+ };
+ }, [roomCode, refresh]);
+
+ async function dispatchEntry(
+ attempt: RoomEntryAttempt,
+ firstDispatch = false,
+ ) {
+ if (
+ mutationPending.current ||
+ recovery.current ||
+ pendingEntry.current !== attempt ||
+ seatClaimUncertain.current
+ )
+ return;
+ const requestEpoch = ++epoch.current;
+ mutationPending.current = true;
+ setNotice('');
+ setBusy(true);
+ try {
+ const data = await requestJson(roomEntryUrl(attempt), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: attempt.bodyText,
+ });
+ if (requestEpoch !== epoch.current || pendingEntry.current !== attempt)
+ return;
+ if (
+ !roomEntryConfirmed(attempt, data, firstDispatch) ||
+ data.version < (knownVersions.current.get(data.code) ?? -1)
+ )
+ throw new ClientRequestError(
+ 'The response did not confirm this saved room request. Its proof is retained; retry the same request or deliberately abandon it.',
+ 'invalid-response',
+ );
+ try {
+ clearRoomEntry(window.sessionStorage);
+ pendingEntry.current = null;
+ setEntryAttempt(null);
+ setEntryProblem('');
+ } catch {
+ setNotice(
+ 'Your seat is confirmed, but this tab could not confirm removal of its private retry proof. Review the saved request when storage is available.',
+ );
+ }
+ activeRoom.current = data.code;
+ activeSeat.current = data.me;
+ knownVersions.current.set(data.code, data.version);
+ recovery.current = null;
+ botAttempt.current = null;
+ setBotRecovery(false);
+ setNeedsReconcile(false);
+ setShowEntryRecovery(false);
+ setAbandonAcknowledged(false);
+ setGame(data);
+ window.history.replaceState(null, '', `/?room=${data.code}`);
+ } catch (error) {
+ if (requestEpoch === epoch.current && pendingEntry.current === attempt) {
+ if (roomEntryRejectedBeforeCommit(firstDispatch, error)) {
+ try {
+ clearRoomEntry(window.sessionStorage);
+ pendingEntry.current = null;
+ setEntryAttempt(null);
+ setEntryProblem('');
+ setNotice((error as Error).message);
+ return;
+ } catch {
+ setNotice(
+ `${(error as Error).message} Nothing was committed by this request, but removal of its saved proof could not be confirmed. Restore browser storage and reload, or explicitly abandon the saved request.`,
+ );
+ return;
+ }
+ }
+ const uncertain = requestMayHaveCompleted(error);
+ setNotice(
+ uncertain
+ ? `${(error as Error).message} The request may have completed. Use the explicit retry to send its exact saved proof; it has not been sent again automatically.`
+ : `${(error as Error).message} The saved request is retained. Retry it unchanged, or deliberately abandon it before changing your details.`,
+ );
+ }
+ } finally {
+ mutationPending.current = false;
+ setBusy(false);
+ }
+ }
+
+ async function enter(join = false) {
+ if (
+ mutationPending.current ||
+ recovery.current ||
+ pendingEntry.current ||
+ seatClaimUncertain.current ||
+ entryProblem
+ )
+ return;
+ let attempt: RoomEntryAttempt;
+ try {
+ attempt = createRoomEntry(
+ { name, faction: selected, expansions, advanced: false },
+ join ? code : undefined,
+ );
+ } catch {
+ setNotice(
+ 'Check your name, room code and faction before trying again. Nothing was sent.',
+ );
+ return;
+ }
+ try {
+ // Storage must succeed before dispatch. Never overwrite an unresolved proof.
+ saveRoomEntry(window.sessionStorage, attempt);
+ } catch {
+ setEntryProblem(
+ 'The request could not be safely saved in this tab. Nothing was sent. A saved record may still be present; restore access to browser storage and reload, or explicitly abandon the record below.',
+ );
+ return;
+ }
+ pendingEntry.current = attempt;
+ setEntryAttempt(attempt);
+ setAbandonAcknowledged(false);
+ await dispatchEntry(attempt, true);
+ }
+
+ function exitTable() {
+ if (
+ mutationPending.current ||
+ reconnectPending.current ||
+ seatClaimUncertain.current
+ )
+ return;
+ activeRoom.current = null;
+ activeSeat.current = null;
+ ++epoch.current;
+ recovery.current = null;
+ botAttempt.current = null;
+ setNeedsReconcile(false);
+ setBotRecovery(false);
+ setConnection('');
+ setGame(null);
+ setNotice('');
+ window.history.replaceState(null, '', '/');
+ }
+
+ function abandonEntry() {
+ if (
+ mutationPending.current ||
+ seatClaimUncertain.current ||
+ !abandonAcknowledged
+ )
+ return;
+ try {
+ clearRoomEntry(window.sessionStorage);
+ pendingEntry.current = null;
+ setEntryAttempt(null);
+ setEntryProblem('');
+ setShowEntryRecovery(false);
+ setAbandonAcknowledged(false);
+ setNotice(
+ 'The saved retry proof was removed. This did not cancel a room or seat already created on the server.',
+ );
+ } catch {
+ setNotice(
+ 'Removal of the saved proof could not be confirmed. Restore access to this tab’s storage before abandoning this request.',
+ );
+ }
+ }
+
+ async function send(action: Action) {
+ if (!game || mutationPending.current || recovery.current) return;
+ const room = game.code,
+ seat = game.me,
+ requestEpoch = epoch.current;
+ mutationPending.current = true;
+ setBusy(true);
+ setNotice('');
+ try {
+ const data = await requestJson(`/api/rooms/${room}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ version: game.version, action }),
+ });
+ if (
+ activeRoom.current === room &&
+ activeSeat.current === seat &&
+ epoch.current === requestEpoch
+ ) {
+ knownVersions.current.set(
+ room,
+ Math.max(data.version, knownVersions.current.get(room) ?? -1),
+ );
+ setGame((previous) =>
+ !previous ||
+ previous.code !== room ||
+ data.version >= previous.version
+ ? data
+ : previous,
+ );
+ }
+ } catch (error) {
+ if (
+ activeRoom.current === room &&
+ activeSeat.current === seat &&
+ epoch.current === requestEpoch
+ ) {
+ const uncertain = requestMayHaveCompleted(error);
+ setNotice(
+ uncertain
+ ? `${(error as Error).message} Your action may have completed. It has not been sent again; check the refreshed table.`
+ : (error as Error).message,
+ );
+ await reconcile(room);
+ }
+ } finally {
+ mutationPending.current = false;
+ setBusy(false);
+ }
+ }
+
+ async function reconnect(resumeBots = false) {
+ const room = activeRoom.current;
+ if (!room || mutationPending.current || reconnectPending.current) return;
+ reconnectPending.current = true;
+ setCheckingConnection(true);
+ setConnection('Checking the latest table…');
+ try {
+ const data = await refresh(room, true);
+ if (restoreFailed && activeRoom.current === room) {
+ setRestoreFailed(false);
+ setNotice('');
+ }
+ if (resumeBots) await advanceBots(data, true);
+ } catch (error) {
+ if (activeRoom.current === room) {
+ if (restoreFailed && inviteRejected(error)) {
+ activeRoom.current = null;
+ setRestoreFailed(false);
+ setCode(room);
+ setNotice((error as Error).message);
+ setConnection('');
+ } else
+ setConnection(
+ 'Could not reconnect. Try again when the server is available.',
+ );
+ }
+ } finally {
+ reconnectPending.current = false;
+ setCheckingConnection(false);
+ }
+ }
+ function controlUncertain(uncertain: boolean) {
+ seatClaimUncertain.current = uncertain;
+ setClaimUncertain(uncertain);
+ }
+
+ function controlPending(pending: boolean) {
+ mutationPending.current = pending;
+ setBusy(pending);
+ }
+
+ function restoredSeat(view: GameView, claim = false) {
+ if (
+ !claim &&
+ (activeRoom.current !== view.code || activeSeat.current !== view.me)
+ )
+ return;
+ if (claim) {
+ activeRoom.current = view.code;
+ activeSeat.current = view.me;
+ ++epoch.current;
+ botAttempt.current = null;
+ setBotRecovery(false);
+ window.history.replaceState(null, '', `/?room=${view.code}`);
+ }
+ knownVersions.current.set(
+ view.code,
+ Math.max(view.version, knownVersions.current.get(view.code) ?? -1),
+ );
+ setGame((previous) =>
+ claim ||
+ !previous ||
+ previous.code !== view.code ||
+ view.version >= previous.version
+ ? view
+ : previous,
+ );
+ recovery.current = null;
+ setNeedsReconcile(false);
+ setRestoreFailed(false);
+ setConnection('');
+ setNotice('');
+ }
+ useEffect(() => {
+ const ctx = (
+ document as Document & {
+ modelContext?: {
+ registerTool: (tool: unknown, options: unknown) => Promise;
+ };
+ }
+ ).modelContext;
+ if (!ctx) return;
+ const lifecycle = new AbortController();
+ try {
+ Promise.resolve(
+ ctx.registerTool(
+ {
+ name: 'read_dune_table',
+ description:
+ 'Read the current Dune table, including only information visible to this player.',
+ inputSchema: {
+ type: 'object',
+ properties: {},
+ additionalProperties: false,
+ },
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
+ execute: () =>
+ roomCode ? refresh(roomCode) : { status: 'not_seated' },
+ },
+ { signal: lifecycle.signal },
+ ),
+ ).catch(() => {});
+ } catch {}
+ return () => lifecycle.abort();
+ }, [roomCode, refresh]);
+ if (game)
+ return (
+ <>
+
+
+ {(entryAttempt || entryProblem) && (
+
+
+ A previous room request still has private retry proof saved in
+ this tab. Opening this seat did not remove it.
+
+
+ Review saved room request
+
+
+ )}
+
restoredSeat(view)}
+ />
+
+ {(notice || connection || needsReconcile || botRecovery) && (
+
+ {notice && {notice} }
+ {connection && {connection} }
+ {needsReconcile && (
+ Actions paused until the current table is confirmed.
+ )}
+ {(connection || needsReconcile) && (
+ void reconnect()}
+ >
+ Reconnect
+
+ )}
+ {botRecovery && (
+ <>
+
+ AI progress was not confirmed. Check the table before
+ resuming.
+
+ void reconnect(true)}
+ >
+ Check and resume AI
+
+ >
+ )}
+ setNotice('')} aria-label="Dismiss message">
+ ×
+
+
+ )}
+ >
+ );
+ if (restoring || restoreFailed)
+ return (
+
+
+
+ DUNEARRAKIS TABLE
+
+
+
+
+ {restoring
+ ? 'Restoring your table'
+ : 'Your table could not be restored'}
+
+
+ {restoring
+ ? 'Checking your saved seat and the latest game state…'
+ : connection || notice}
+
+ {restoreFailed && !restoring && (
+ <>
+
+ Retry to check your saved seat and current game. This does not
+ join a new seat or take a game action.
+
+ void reconnect()}
+ >
+ Retry restoration
+
+ {
+ activeRoom.current = null;
+ setRestoreFailed(false);
+ setConnection('');
+ setNotice('');
+ }}
+ >
+ Open join form
+
+ >
+ )}
+
+
+ );
+ if (entryAttempt || entryProblem)
+ return (
+
+
+
+ DUNEARRAKIS TABLE
+
+
+
+
+ {entryAttempt
+ ? `Saved ${entryAttempt.kind === 'create' ? 'room creation' : 'room join'} request`
+ : 'Saved request needs attention'}
+
+ {entryAttempt && (
+
+ {entryAttempt.kind === 'join'
+ ? `Room ${entryAttempt.roomCode}. `
+ : ''}
+ Prepared {new Date(entryAttempt.createdAt).toLocaleString()}.
+
+ )}
+
+ This tab keeps your retry details through refresh. Keep it open
+ until the request is resolved; closing it or clearing browser data
+ can remove those details.
+
+
+ A response may have been lost after the server created a room or
+ seat. Retry sends the exact same request and private proof. It does
+ not start a new attempt. No request is retried automatically.
+
+ {entryProblem && {entryProblem}
}
+ {notice && {notice} }
+ {entryAttempt && (
+ void dispatchEntry(entryAttempt)}
+ >
+ Retry same {entryAttempt.kind === 'create' ? 'create' : 'join'}{' '}
+ request
+
+ )}
+
+ Keep this proof until the request is resolved. Abandoning removes
+ only this tab’s retry proof; it cannot undo a room or seat already
+ created. You may lose access without a saved recovery kit, and a new
+ attempt can create a duplicate room or seat.
+
+
+ setAbandonAcknowledged(event.target.checked)}
+ />{' '}
+ I understand that abandoning can lose access to a completed request.
+
+
+ Abandon saved request
+
+
+ A previously saved seat recovery kit can still be used. This keeps
+ the room-entry proof; the recovered seat may be different.
+
+ setShowEntryRecovery((shown) => !shown)}
+ >
+ {showEntryRecovery
+ ? 'Hide saved-kit recovery'
+ : 'Use a saved recovery kit'}
+
+ {claimUncertain && (
+
+ Seat recovery may have completed. Retry the current recovery
+ request below first. Room-entry retry, abandonment and navigation
+ are paused to preserve its exact recovery details.
+
+ )}
+ {(showEntryRecovery || claimUncertain) && (
+ restoredSeat(view, true)}
+ />
+ )}
+
+
+ );
+ return (
+
+
+
+
+ 01 / CHOOSE YOUR FACTION
+
+ A seat in the
+
+ Imperium.
+
+
+ {FACTIONS.filter(
+ (x) => x.expansion === 'base' || expansions.includes(x.expansion),
+ ).map((x) => (
+ setSelected(x.id)}
+ style={{ '--faction-color': x.color } as React.CSSProperties}
+ >
+ {x.sigil}
+
+ {x.name}
+ {x.title}
+
+
+
+ ))}
+
+
+
+
+ ARRAKIS
+ 89.04° N / IMPERIAL SURVEY
+
+
+
+
+
+
+ POLAR SINK
+
+ {[
+ 'Arrakeen',
+ 'Carthag',
+ 'Tuek’s Sietch',
+ 'Habbanya Sietch',
+ 'Sietch Tabr',
+ ].map((t, i) => (
+
+
+ {t}
+
+ ))}
+
+
N
+
+
+
YOUR HOUSE
+
{f.name}
+
{f.description}
+
+
+ {f.spice} Starting spice
+
+
+ 20 Forces
+
+
+ {f.revival} Free revival
+
+
+
+
+ THE SPICE MUST FLOW
+ CLASSIC / 2019
+
+
+
+ 02 / ASSEMBLE THE TABLE
+ Begin a game
+
+ Invite your rivals. Decide the fate of Arrakis.
+
+ Your name
+ setName(e.target.value)}
+ placeholder="Enter your name"
+ maxLength={32}
+ />
+
+ Rules
+ BASIC
+
+ 2–6 players · up to 10 turns
+
+ Expansions
+ Optional modules
+
+
+ {EXPANSIONS.map((x) => (
+
+ {
+ setExpansions(
+ e.target.checked
+ ? [...expansions, x.id]
+ : expansions.filter((v) => v !== x.id),
+ );
+ setSelected('atreides');
+ }}
+ />
+ {x.name}
+
+ ))}
+
+ void enter()}
+ >
+
+ Create a room
+
+
+ OR JOIN YOUR FRIENDS
+ Room code
+
+
setCode(e.target.value.toUpperCase())}
+ placeholder="ABCD2345"
+ maxLength={8}
+ />
+
void enter(true)}
+ aria-label="Join room"
+ >
+
+
+
+ restoredSeat(view, true)}
+ />
+ {claimUncertain && (
+
+ Seat recovery may have completed. Retry the current recovery
+ request before creating or joining a room; keep this page open to
+ preserve its exact details.
+
+ )}
+ {notice && {notice} }
+
+
+
+ Private hands. Shared board.
+
+ Every decision matters.
+
+
+
+ In development · Rules verification in progress.
+
+
+
+
+ Unofficial fan implementation
+ Dune · A game of conquest, diplomacy & betrayal
+
+
+ );
+}
diff --git a/app/rules/coverage/page.tsx b/app/rules/coverage/page.tsx
new file mode 100644
index 00000000..f41bffea
--- /dev/null
+++ b/app/rules/coverage/page.tsx
@@ -0,0 +1,122 @@
+import Link from 'next/link';
+export default function Rules() {
+ return (
+
+ ← Return to the table
+ Rules coverage ledger
+
+ 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.
+
+
+
+
+ Area
+ Current implementation
+ Verification remaining
+
+
+
+ {[
+ [
+ '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]) => (
+
+ {a}
+ {b}
+ {c}
+
+ ))}
+
+
+
+ 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.
+
+ About this adaptation
+
+ Rules are being checked against the GF9 base rulebook, November 2020
+ FAQ, and the Ixians & Tleilaxu, CHOAM & Richese, and Ecaz &
+ Moritani expansion rulebooks.
+
+
+ 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.
+
+
+ );
+}
diff --git a/app/rules/page.tsx b/app/rules/page.tsx
new file mode 100644
index 00000000..6b950d0e
--- /dev/null
+++ b/app/rules/page.tsx
@@ -0,0 +1,4 @@
+import { RulesReference } from '@/components/rules-reference';
+export default function Rules() {
+ return ;
+}
diff --git a/components.json b/components.json
new file mode 100644
index 00000000..f382eb79
--- /dev/null
+++ b/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-nova",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
+}
diff --git a/components/ambassador-movement.tsx b/components/ambassador-movement.tsx
new file mode 100644
index 00000000..d6014fda
--- /dev/null
+++ b/components/ambassador-movement.tsx
@@ -0,0 +1,308 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { location, splitLocation, territory } from '@/game/board';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+
+const bounded = (value: number | undefined, minimum: number, maximum: number) =>
+ Math.max(
+ minimum,
+ Math.min(maximum, Number.isSafeInteger(value) ? value! : minimum),
+ );
+
+/** A draft belongs to this Ambassador event, never to the entrant's normal move. */
+export function AmbassadorMovement({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const [sourceId, setSourceId] = useState('');
+ const [destinationKey, setDestinationKey] = useState('');
+ const [forces, setForces] = useState>({});
+ const [elites, setElites] = useState>({});
+ const [takeMarker, setTakeMarker] = useState(false);
+ const [fighters, setFighters] = useState(false);
+ const entry = game.ambassadorEntry;
+ if (
+ entry?.stage !== 'move' ||
+ entry.beneficiary !== game.me ||
+ game.decision?.kind !== 'ecazAmbassador' ||
+ game.decision.player !== game.me ||
+ !entry.movement
+ )
+ return null;
+ const sources = entry.movement.sources;
+ const source = sources.find((s) => s.territory === sourceId) ?? sources[0];
+ if (!source)
+ return (
+
+ No legal relocation is currently available.
+
+ );
+ const destinations = source.destinations;
+ const destination =
+ destinations.find(
+ (d) => location(d.territory, d.sector) === destinationKey,
+ ) ??
+ destinations.find((d) => !d.blocked) ??
+ destinations[0];
+ const target = destination
+ ? location(destination.territory, destination.sector)
+ : '';
+ const rows = source.sectors.map((s) => {
+ const count = s.key === target ? 0 : bounded(forces[s.key], 0, s.forces);
+ const minimum = Math.max(0, count - (s.forces - s.elites));
+ return {
+ ...s,
+ count,
+ minimum,
+ maximum: Math.min(count, s.elites),
+ elite: bounded(elites[s.key], minimum, Math.min(count, s.elites)),
+ };
+ });
+ const selectedForces = Object.fromEntries(
+ rows.filter((s) => s.count > 0).map((s) => [s.key, s.count]),
+ );
+ const selectedElites = Object.fromEntries(
+ rows.filter((s) => s.count > 0).map((s) => [s.key, s.elite]),
+ );
+ const total = rows.reduce((n, s) => n + s.count, 0);
+ const markerAtTarget =
+ !!source.marker &&
+ location(source.territory, source.marker.sector) === target;
+ const includesMarker = !!source.marker && takeMarker && !markerAtTarget;
+ const reason =
+ destination?.blocked ??
+ (!destination
+ ? 'Choose an available destination.'
+ : !total && !includesMarker
+ ? 'Choose physical forces or the concealed No-Field to relocate.'
+ : null);
+ const chooseDestination = (key: string) => {
+ setDestinationKey(key);
+ setForces((old) => ({ ...old, [key]: 0 }));
+ setElites((old) => ({ ...old, [key]: 0 }));
+ setFighters(false);
+ };
+ const targetTerritories = [...new Set(destinations.map((d) => d.territory))];
+ return (
+
+
+ Relocate a group from one territory. This does not spend your ordinary
+ shipment or movement. The Ambassador has already been triggered.
+
+
+ Source territory
+ {
+ setSourceId(event.target.value);
+ setDestinationKey('');
+ setForces({});
+ setElites({});
+ setTakeMarker(false);
+ setFighters(false);
+ }}
+ >
+ {sources.map((s) => (
+
+ {territory(s.territory).name}
+
+ ))}
+
+
+ {rows.map((s) => (
+
+
+ Sector {splitLocation(s.key).sector} · {s.forces} physical forces
+ available
+
+
+ Physical forces to relocate
+
+ setForces({
+ ...forces,
+ [s.key]: bounded(Number(e.target.value), 0, s.forces),
+ })
+ }
+ />
+
+ {s.elites > 0 && (
+
+ Elite forces included · {s.minimum}–{s.maximum}
+
+ setElites({
+ ...elites,
+ [s.key]: bounded(
+ Number(e.target.value),
+ s.minimum,
+ s.maximum,
+ ),
+ })
+ }
+ />
+
+ )}
+ {s.key === target && (
+
+ Forces already in the destination sector stay in place.
+
+ )}
+
+ ))}
+ {source.marker && (
+
+
+ setTakeMarker(e.target.checked)}
+ />
+ Include concealed No-Field from sector {source.marker.sector}
+
+
+ The marker can move alone or with physical forces. Its concealed
+ value stays unchanged.
+ {markerAtTarget
+ ? ' It is already in the destination sector and stays in place.'
+ : ''}
+
+
+ )}
+
+ Destination territory
+ {
+ const options = destinations.filter(
+ (d) => d.territory === e.target.value,
+ );
+ const chosen = options.find((d) => !d.blocked) ?? options[0];
+ if (chosen)
+ chooseDestination(location(chosen.territory, chosen.sector));
+ }}
+ >
+ {targetTerritories.map((t) => (
+
+ {territory(t).name}
+
+ ))}
+
+
+
+ Destination sector
+ chooseDestination(e.target.value)}
+ >
+ {destinations
+ .filter((d) => d.territory === destination?.territory)
+ .map((d) => (
+
+ {d.sector === 0 ? 'Sheltered interior' : d.sector}
+ {d.blocked ? ' · Unavailable' : ''}
+
+ ))}
+
+
+ {destination?.advisors && (
+
This group arrives as advisors.
+ )}
+ {destination?.canFight && (
+
+ setFighters(e.target.checked)}
+ />
+ Request a flip to fighters on arrival
+
+ )}
+
+ Selected: {total} physical forces
+ {includesMarker ? ' and one concealed No-Field' : ''}.
+
+ {reason && (
+
+ {reason}
+
+ )}
+
{
+ if (busy || reason || !destination) return;
+ act({
+ type: 'decision',
+ event: entry.event,
+ forces: selectedForces,
+ eliteForces: selectedElites,
+ territory: destination.territory,
+ sector: destination.sector,
+ ...(includesMarker && source.marker
+ ? {
+ noField: {
+ tokenId: source.marker.tokenId,
+ event: source.marker.event,
+ },
+ }
+ : {}),
+ ...(fighters && destination.canFight ? { fighters: true } : {}),
+ });
+ }}
+ >
+ Relocate selected forces
+
+
+ );
+}
diff --git a/components/ambassador-shipment.tsx b/components/ambassador-shipment.tsx
new file mode 100644
index 00000000..1349b4e4
--- /dev/null
+++ b/components/ambassador-shipment.tsx
@@ -0,0 +1,196 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { location, territory } from '@/game/board';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+
+const bounded = (value: number, min: number, max: number) =>
+ Math.max(min, Math.min(max, Number.isSafeInteger(value) ? value : min));
+
+/** Mounted with the Ambassador event key so an earlier shipment draft cannot carry over. */
+export function AmbassadorShipment({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const [destinationKey, setDestinationKey] = useState('');
+ const [count, setCount] = useState(null);
+ const [eliteCount, setEliteCount] = useState(0);
+ const entry = game.ambassadorEntry;
+ if (
+ entry?.stage !== 'ship' ||
+ entry.beneficiary !== game.me ||
+ game.decision?.kind !== 'ecazAmbassador' ||
+ game.decision.player !== game.me ||
+ !entry.shipment
+ )
+ return null;
+ const me = game.players.find((p) => p.id === game.me)!;
+ const shipment = entry.shipment;
+ const maximum = Math.min(shipment.maximum, me.reserves);
+ const amount = maximum ? bounded(count ?? maximum, 1, maximum) : 0;
+ const minimumElite = Math.max(
+ 0,
+ amount - (me.reserves - shipment.eliteReserves),
+ );
+ const maximumElite = Math.min(amount, shipment.eliteReserves);
+ const elite = bounded(eliteCount, minimumElite, maximumElite);
+ const destination =
+ shipment.destinations.find(
+ (d) => location(d.territory, d.sector) === destinationKey,
+ ) ??
+ shipment.destinations.find((d) => !d.blocked) ??
+ shipment.destinations[0];
+ const reason = !amount
+ ? 'You have no reserve forces available for this shipment.'
+ : !destination
+ ? 'No shipment destination is currently available.'
+ : destination.blocked;
+ const territories = [
+ ...new Set(shipment.destinations.map((d) => d.territory)),
+ ];
+ return (
+
+
+ Send up to four of your reserve forces for free, or send no forces. This
+ does not spend your ordinary shipment or movement. The Ambassador has
+ already been triggered.
+
+
+ Physical forces to ship · up to {maximum}
+
+ setCount(bounded(Number(e.target.value), 1, maximum))
+ }
+ />
+
+ {shipment.eliteReserves > 0 && (
+
+ Elite forces included · {minimumElite}–{maximumElite}
+
+ setEliteCount(
+ bounded(Number(e.target.value), minimumElite, maximumElite),
+ )
+ }
+ />
+
+ )}
+
+ Destination territory
+ {
+ const options = shipment.destinations.filter(
+ (d) => d.territory === e.target.value,
+ );
+ const chosen = options.find((d) => !d.blocked) ?? options[0];
+ if (chosen)
+ setDestinationKey(location(chosen.territory, chosen.sector));
+ }}
+ >
+ {territories.map((t) => (
+
+ {territory(t).name}
+
+ ))}
+
+
+
+ Destination sector
+ setDestinationKey(e.target.value)}
+ >
+ {shipment.destinations
+ .filter((d) => d.territory === destination?.territory)
+ .map((d) => (
+
+ {d.sector === 0 ? 'Sheltered interior' : d.sector}
+ {d.blocked ? ' · Unavailable' : ''}
+
+ ))}
+
+
+ {destination?.advisors && (
+
These forces join your advisors.
+ )}
+
+ Selected: {amount} physical forces, including {elite} elites. Cost: 0
+ spice.
+
+
+ No-Field substitution for this effect remains unfinished.
+
+ {reason && (
+
+ {reason}
+
+ )}
+
{
+ if (!busy && !reason && destination)
+ act({
+ type: 'decision',
+ event: entry.event,
+ amount,
+ elite,
+ territory: destination.territory,
+ sector: destination.sector,
+ });
+ }}
+ >
+ Ship selected forces for free
+
+
{
+ if (!busy) act({ type: 'decision', event: entry.event, amount: 0 });
+ }}
+ >
+ Send no forces
+
+
+ );
+}
diff --git a/components/automatic-action-notice.tsx b/components/automatic-action-notice.tsx
new file mode 100644
index 00000000..9515c910
--- /dev/null
+++ b/components/automatic-action-notice.tsx
@@ -0,0 +1,176 @@
+'use client';
+
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from 'react';
+import { Bell, BellOff } from 'lucide-react';
+import { FACTIONS, type FactionId } from '@/game/catalog';
+import { Button } from './ui/button';
+
+export type AutomaticActionEvent = {
+ id: string;
+ seq: number;
+ faction: FactionId;
+ name: string;
+};
+
+const PREFERENCE_KEY = 'dune.automatic-action-notices';
+const PREFERENCE_EVENT = 'dune-automatic-action-notices-preference';
+const MAX_QUEUED_NOTICES = 250;
+let memoryPreference: boolean | undefined;
+function readPreference() {
+ if (memoryPreference !== undefined) return memoryPreference;
+ try {
+ return localStorage.getItem(PREFERENCE_KEY) !== 'false';
+ } catch {
+ return true;
+ }
+}
+function subscribePreference(listener: () => void) {
+ window.addEventListener(PREFERENCE_EVENT, listener);
+ return () => window.removeEventListener(PREFERENCE_EVENT, listener);
+}
+const serverPreference = () => true;
+
+/** Cosmetic feedback only. Supply public events with increasing, room-local sequences. */
+export function AutomaticActionNotice({
+ events,
+}: {
+ events: readonly AutomaticActionEvent[];
+}) {
+ const enabled = useSyncExternalStore(
+ subscribePreference,
+ readPreference,
+ serverPreference,
+ );
+ // The first snapshot establishes a baseline. Refreshing never replays history.
+ const seenSeq = useRef(
+ events.reduce((last, event) => Math.max(last, event.seq), -1),
+ );
+ const queue = useRef([]);
+ const active = useRef(null);
+ const [notice, setNotice] = useState<
+ (AutomaticActionEvent & { fading: boolean }) | null
+ >(null);
+ const [completed, setCompleted] = useState(0);
+ const timers = useRef[]>([]);
+ const clearTimers = useCallback(() => {
+ for (const timer of timers.current) clearTimeout(timer);
+ timers.current = [];
+ }, []);
+
+ const showNext = useCallback(() => {
+ if (active.current) return;
+ if (!readPreference()) {
+ queue.current = [];
+ return;
+ }
+ const next = queue.current.shift();
+ if (!next) return;
+ active.current = next;
+ clearTimers();
+ timers.current = [
+ setTimeout(() => {
+ setNotice({ ...next, fading: false });
+ }, 0),
+ setTimeout(() => {
+ setNotice((current) =>
+ current?.id === next.id ? { ...current, fading: true } : current,
+ );
+ }, 1250),
+ setTimeout(() => {
+ active.current = null;
+ setNotice(null);
+ setCompleted((count) => count + 1);
+ }, 1500),
+ ];
+ }, [clearTimers]);
+
+ useEffect(() => {
+ const fresh = events
+ .filter((event) => event.seq > seenSeq.current)
+ .toSorted((a, b) => a.seq - b.seq);
+ const additions: AutomaticActionEvent[] = [];
+ for (const event of fresh) {
+ if (event.seq <= seenSeq.current) continue;
+ seenSeq.current = event.seq;
+ additions.push(event);
+ }
+ // Disabled notices are consumed, so enabling cannot replay missed events.
+ if (!readPreference()) return;
+ const capacity = MAX_QUEUED_NOTICES - (active.current ? 1 : 0);
+ // Preserve the active notice and retain the newest pending events on overflow.
+ queue.current = [...queue.current, ...additions].slice(-capacity);
+ showNext();
+ }, [events, showNext, completed]);
+
+ useEffect(
+ () => () => {
+ clearTimers();
+ queue.current = [];
+ active.current = null;
+ },
+ [clearTimers],
+ );
+
+ const toggle = () => {
+ const next = !enabled;
+ memoryPreference = next;
+ clearTimers();
+ queue.current = [];
+ active.current = null;
+ setNotice(null);
+ try {
+ localStorage.setItem(PREFERENCE_KEY, String(next));
+ memoryPreference = undefined;
+ } catch {
+ // Do not turn a cosmetic preference into an error or blocking dialog.
+ }
+ window.dispatchEvent(new Event(PREFERENCE_EVENT));
+ };
+ const house = notice
+ ? FACTIONS.find((candidate) => candidate.id === notice.faction)
+ : undefined;
+
+ return (
+ <>
+
+ {enabled ? : }
+ Automatic action notices
+ {enabled ? 'On' : 'Off'}
+
+
+ {enabled && notice && (
+
+
+ {house?.name ?? 'Automatic action'}
+
+
+ {notice.name}
+
+
+ )}
+
+ >
+ );
+}
diff --git a/components/battle-component-inspection.tsx b/components/battle-component-inspection.tsx
new file mode 100644
index 00000000..0eeec47f
--- /dev/null
+++ b/components/battle-component-inspection.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import { CardInspector } from './card-inspector';
+import {
+ LeaderInspector,
+ type LeaderDisplayIdentity,
+} from './leader-inspector';
+import type { VisibleCard } from '@/game/card-presentation';
+
+/** The parent supplies only faces already authorized by its private game view. */
+export function BattleComponentInspection({
+ cards,
+ leader,
+ playerName,
+}: {
+ cards: readonly VisibleCard[];
+ leader?: LeaderDisplayIdentity;
+ playerName: string;
+}) {
+ if (!cards.length && !leader) return null;
+ return (
+
+ Inspect {playerName}’s plan components
+
+ {leader && (
+
+ {leader.name}
+
+
+ )}
+ {cards.map((card) => (
+
+ {card.name}
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/battle-preparation.tsx b/components/battle-preparation.tsx
new file mode 100644
index 00000000..c4377cae
--- /dev/null
+++ b/components/battle-preparation.tsx
@@ -0,0 +1,216 @@
+'use client';
+import { controlsLeader } from '@/game/leader-control';
+import { leaderStrengthLabel } from '@/game/cards';
+import {
+ battleCardLabel,
+ isWeaponCard,
+ isStoneBurner,
+ isDefenseCard,
+ VOICE_KINDS,
+} from '@/game/battle-cards';
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import type { Action, GameView } from '@/game/engine';
+
+export function BattlePreparation({
+ game,
+ send,
+ busy,
+}: {
+ game: GameView;
+ send: (action: Action) => Promise;
+ busy: boolean;
+}) {
+ const b = game.battle!;
+ const preparation = b.preparation!;
+ const me = game.players.find((p) => p.id === game.me)!;
+ const [kind, setKind] = useState('poison');
+ const [must, setMust] = useState(false);
+ const [value, setValue] = useState('');
+ const act = (action: Action) => void send(action);
+ const beneficiary = game.players.find(
+ (p) => p.id === preparation.beneficiary,
+ )!;
+ if (preparation.owner !== game.me)
+ return (
+
+ Waiting for {game.players.find((p) => p.id === preparation.owner)?.name}
+ {preparation.kind === 'voice'
+ ? ' to choose or decline the Voice.'
+ : preparation.kind === 'prescience'
+ ? ' to choose or decline prescience.'
+ : ' to reveal the requested battle-plan element.'}
+
+ );
+ if (preparation.kind === 'voice')
+ return (
+ <>
+ The Voice
+
+ {beneficiary.id === me.id
+ ? 'Command your opponent'
+ : `Assist ${beneficiary.name}`}{' '}
+ before Atreides chooses prescience and the combatants seal their
+ plans.
+
+
+ Card type
+ setKind(e.target.value)}
+ >
+ {VOICE_KINDS.map((k) => (
+
+ {battleCardLabel(k)}
+
+ ))}
+
+
+
+ setMust(e.target.checked)}
+ />
+ Require this type (unchecked: forbid)
+
+ act({ type: 'voice', kind, must })}
+ >
+ Use the Voice
+
+ act({ type: 'declineBattlePower' })}
+ >
+ Decline the Voice
+
+ >
+ );
+ const prescienceTarget =
+ beneficiary.id === b.attacker ? b.defender : b.attacker;
+ const noFieldTarget = b.noFieldPlayers.includes(prescienceTarget);
+ if (preparation.kind === 'prescience')
+ return (
+ <>
+ Battle prescience
+
+ Choose one element of{' '}
+ {beneficiary.id === me.id ? 'your' : `${beneficiary.name}’s`}{' '}
+ opponent’s plan. Only that element becomes fixed.
+
+ {noFieldTarget && (
+
+ A No-Field battle keeps the number dialed hidden from Atreides. You
+ can inspect the leader, weapon, or defense.
+
+ )}
+ {[
+ 'leader',
+ 'weapon',
+ 'defense',
+ ...(noFieldTarget ? [] : ['dial']),
+ ].map((field) => (
+ act({ type: 'prescience', field })}
+ >
+ Reveal {field}
+
+ ))}
+ act({ type: 'declineBattlePower' })}
+ >
+ Decline prescience
+
+ >
+ );
+ const field = b.prescience!.field;
+ const stoneReason =
+ field === 'weapon' && isStoneBurner(me.hand?.find((c) => c.id === value))
+ ? b.stoneBurnerContext?.blocked
+ : null;
+ const choices =
+ field === 'leader'
+ ? [
+ ...me.leaders
+ .filter(
+ (l) =>
+ !l.dead &&
+ controlsLeader(me, l) &&
+ (!l.usedAt || l.usedAt === b.territory),
+ )
+ .map((l) => ({
+ id: l.id,
+ name: `${l.name} · ${leaderStrengthLabel(l)}`,
+ })),
+ ...(me.hand?.filter((c) => c.kind === 'hero') ?? []),
+ ]
+ : (me.hand?.filter((c) =>
+ field === 'weapon' ? isWeaponCard(c) : isDefenseCard(c),
+ ) ?? []);
+ return (
+ <>
+ Reveal your {field}
+
+ {beneficiary.name} will see this element. You may still change the rest
+ of your plan before sealing it.
+
+ {b.voice && (
+
+ Voice: {b.voice.must ? 'must play' : 'cannot play'}{' '}
+ {battleCardLabel(b.voice.kind)}.
+
+ )}
+
+ {field === 'dial' ? 'Forces dialed' : field}
+ {field === 'dial' ? (
+ setValue(e.target.value)}
+ />
+ ) : (
+ setValue(e.target.value)}
+ >
+ None
+ {choices.map((choice) => (
+
+ {choice.name}
+
+ ))}
+
+ )}
+
+ {stoneReason && {stoneReason}
}
+
+ act({
+ type: 'prescienceAnswer',
+ value: field === 'dial' ? Number(value) : value || null,
+ })
+ }
+ >
+ Commit this element
+
+ >
+ );
+}
diff --git a/components/battle-promises.tsx b/components/battle-promises.tsx
new file mode 100644
index 00000000..39126b56
--- /dev/null
+++ b/components/battle-promises.tsx
@@ -0,0 +1,239 @@
+'use client';
+import { useId } from 'react';
+import { HelpTip } from './help-tip';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
+import { planClaimText, type PlanClaim } from '@/game/battle-promises';
+import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
+import type { Action, GameView, Plan } from '@/game/engine';
+
+export function BattleClaimFields({
+ game: g,
+ value: c,
+ onChange,
+}: {
+ game: GameView;
+ value: PlanClaim;
+ onChange: (c: PlanClaim) => void;
+}) {
+ const amountId = useId();
+ const initial = (kind: string): PlanClaim =>
+ kind === 'dial' || kind === 'support'
+ ? { kind, compare: 'eq', value: 0 }
+ : kind === 'weapon' || kind === 'defense'
+ ? { kind, name: null }
+ : kind === 'leader'
+ ? { kind, leader: g.allLeaders[0].id }
+ : { kind: 'kwisatz', use: true };
+ return (
+ <>
+
+ Plan element
+ onChange(initial(e.target.value))}
+ >
+ Dial
+ Leader or hero
+ Weapon slot
+ Defense slot
+ Spice support
+ Kwisatz Haderach
+
+
+ {(c.kind === 'dial' || c.kind === 'support') && (
+ <>
+
+ Comparison
+
+ onChange({
+ ...c,
+ compare: e.target.value as 'eq' | 'gte' | 'lte',
+ })
+ }
+ >
+ Exactly
+ At least
+ At most
+
+
+
+ Amount
+
+ onChange({ ...c, value: Number(e.target.value) })
+ }
+ />
+
+ >
+ )}
+ {(c.kind === 'weapon' || c.kind === 'defense') && (
+
+ Card name
+ onChange({ ...c, name: e.target.value || null })}
+ >
+ Empty slot
+ {TRUTH_CARD_NAMES.map((name) => (
+ {name}
+ ))}
+
+
+ )}
+ {c.kind === 'leader' && (
+
+ Leader
+ onChange({ ...c, leader: e.target.value || null })}
+ >
+ No leader or hero
+ {g.allLeaders.map((l) => (
+
+ {l.name}
+
+ ))}
+ Cheap Hero / Heroine
+
+
+ )}
+ {c.kind === 'kwisatz' && (
+
+ Kwisatz use
+ onChange({ ...c, use: e.target.value === 'yes' })}
+ >
+ Included
+ Not included
+
+
+ )}
+ >
+ );
+}
+export function BattlePromises({
+ game: g,
+ fill,
+ act,
+ busy,
+}: {
+ game: GameView;
+ fill: (plan: Plan) => void;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const b = g.battle;
+ if (!b || (!b.truthPromises.length && !b.compliantPreparation)) return null;
+ const me = g.players.find((p) => p.id === g.me)!;
+ const name = (id: string | null) =>
+ id === null
+ ? 'None'
+ : (me.hand?.find((c) => c.id === id)?.name ??
+ g.allLeaders.find((l) => l.id === id)?.name ??
+ id);
+ const plan = b.compliantPlan;
+ return (
+
+
+ {b.truthPromises.length
+ ? 'Truthtrance commitments'
+ : 'Preparing your committed plan'}{' '}
+
+
+
+ {b.compliantPreparation && (
+
+
Preparation · private
+ {b.compliantPreparation.waitingForIncome && (
+
Revival income must resolve before this plan can be funded.
+ )}
+ {b.compliantPreparation.actions.map((action, index) => (
+
+ {index + 1}.{' '}
+ {action.mode === 'special'
+ ? `Spend Karama and discard ${(action.cards as string[]).map(name).join(', ')} to gain ${(action.cards as string[]).length * 3} spice`
+ : typeof action.leader === 'string'
+ ? `Play Ghola to revive ${action.leader === 'kwisatz' ? 'Kwisatz Haderach' : name(action.leader)}`
+ : `Play Ghola to revive ${Number(action.amount)} force${Number(action.amount) === 1 ? '' : 's'}${Number(action.elite) ? `, including ${Number(action.elite)} elite` : ''}`}
+ .
+
+ ))}
+
+ {b.compliantPreparation.actions.length
+ ? 'The next card is still in your hand. Complete the preparation and any response before reviewing and sealing the plan.'
+ : 'The revival is complete. Resolve its income response before reviewing and sealing the plan.'}{' '}
+ Other legal ways to keep your answer remain available.
+
+ {b.compliantPreparation.actions[0] && (
+
act(b.compliantPreparation!.actions[0])}
+ >
+ Complete next preparation
+
+ )}
+
+ )}
+ {plan && (
+
+ Review a compliant plan · private
+
+ Dial {plan.dial} · Support {plan.support}
+ {!!plan.allyPayment && ` · CHOAM pays ${plan.allyPayment}`}
+
+ Leader: {name(plan.leader)}
+
+ Weapon: {name(plan.weapon)}
+
+ Defense: {name(plan.defense)}
+
+ Kwisatz: {plan.kwisatz ? 'Included' : 'Not included'}
+
+
+ These choices satisfy your current commitments. Review and change
+ the other elements before sealing your plan.
+
+ fill(plan)}>
+ Fill these plan choices
+
+
+ )}
+
+ );
+}
diff --git a/components/battle-wheel.module.css b/components/battle-wheel.module.css
new file mode 100644
index 00000000..29b34ad2
--- /dev/null
+++ b/components/battle-wheel.module.css
@@ -0,0 +1,108 @@
+.root {
+ display: grid;
+ width: 100%;
+ min-width: 0;
+ gap: 0.15rem;
+ color: #f2ddb1;
+ font-variant-numeric: tabular-nums;
+}
+
+.wheel {
+ display: block;
+ width: min(100%, 12rem);
+ height: auto;
+ justify-self: center;
+ overflow: visible;
+ pointer-events: none;
+ user-select: none;
+}
+
+.chosen {
+ fill: #f6e2b8;
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: 34px;
+ font-weight: 600;
+ letter-spacing: -1px;
+}
+
+.controls {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 4.5rem;
+ align-items: start;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.slider {
+ min-width: 0;
+}
+
+.root .range {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ height: 44px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ accent-color: #d8b66a;
+ cursor: pointer;
+}
+
+.bounds {
+ display: flex;
+ justify-content: space-between;
+ margin-top: -0.3rem;
+ color: #bbb39f;
+ font-size: 12px;
+ line-height: 1;
+}
+
+.root .number {
+ box-sizing: border-box;
+ width: 100%;
+ min-width: 0;
+ height: 44px;
+ margin: 0;
+ padding: 0.5rem;
+ border: 1px solid #8e794e;
+ border-radius: 0.35rem;
+ background: #151b13;
+ color: #f6e2b8;
+ font-family: inherit;
+ font-size: 16px;
+ font-variant-numeric: tabular-nums;
+ text-align: center;
+ color-scheme: dark;
+}
+
+.root input:focus-visible {
+ outline: 2px solid #f0d497;
+ outline-offset: 3px;
+}
+
+.root[data-disabled] .wheel {
+ opacity: 0.6;
+}
+
+.root input:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+@media (forced-colors: active) {
+ .root .number {
+ border-color: ButtonText;
+ color: CanvasText;
+ background: Canvas;
+ }
+
+ .root input:focus-visible {
+ outline-color: Highlight;
+ }
+
+ .bounds {
+ color: CanvasText;
+ }
+}
diff --git a/components/battle-wheel.tsx b/components/battle-wheel.tsx
new file mode 100644
index 00000000..230271fc
--- /dev/null
+++ b/components/battle-wheel.tsx
@@ -0,0 +1,186 @@
+'use client';
+
+import { useId } from 'react';
+import styles from './battle-wheel.module.css';
+
+export type BattleWheelProps = {
+ value: number;
+ onChange: (value: number) => void;
+ max: number;
+ step: number;
+ disabled?: boolean;
+ id?: string;
+};
+
+const point = (angle: number, radius: number) => ({
+ x: 100 + Math.sin((angle * Math.PI) / 180) * radius,
+ y: 100 - Math.cos((angle * Math.PI) / 180) * radius,
+});
+
+/** The parent supplies the dial limits and labels the numeric input by its ID. */
+export function BattleWheel({
+ value,
+ onChange,
+ max,
+ step,
+ disabled = false,
+ id = 'forces-dialed',
+}: BattleWheelProps) {
+ const graphicId = useId();
+ const rimId = `${graphicId}-rim`;
+ const faceId = `${graphicId}-face`;
+ const fraction = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0;
+ const angle = -135 + fraction * 270;
+ // This caps decorative detail only; the native controls keep the supplied step.
+ const divisions = Math.min(80, Math.max(1, Math.ceil(max / step)));
+ const labels = [
+ ...new Set(
+ [0, 0.25, 0.5, 0.75, 1].map((position) =>
+ Math.min(max, Math.round((max * position) / step) * step),
+ ),
+ ),
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: divisions + 1 }, (_, index) => {
+ const position = index / divisions;
+ const tickAngle = -135 + position * 270;
+ const major = index % Math.max(1, Math.round(divisions / 4)) === 0;
+ const outer = point(tickAngle, 83);
+ const inner = point(tickAngle, major ? 73 : 78);
+ return (
+
+ );
+ })}
+ {labels.map((mark) => {
+ const label = point(-135 + (max > 0 ? mark / max : 0) * 270, 62);
+ return (
+
+ {Number(mark.toFixed(2))}
+
+ );
+ })}
+
+
+
+
+ {value}
+
+
+ DIALED
+
+
+
+
+
+
+
onChange(Number(event.target.value))}
+ />
+
+ 0
+ {max}
+
+
+
onChange(Number(event.target.value))}
+ />
+
+
+ );
+}
diff --git a/components/card-inspector.tsx b/components/card-inspector.tsx
new file mode 100644
index 00000000..bf7d2cf2
--- /dev/null
+++ b/components/card-inspector.tsx
@@ -0,0 +1,159 @@
+'use client';
+
+import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
+import { Eye, Shield, Sparkles, Swords, UserRound, X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogDescription,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import { cardPresentation, type VisibleCard } from '@/game/card-presentation';
+
+/** Always pass a card from the requesting player's authorized view, never a deck. */
+type CardProps = { card: VisibleCard };
+
+export function CardRules({ card }: CardProps) {
+ return (
+
+ {cardPresentation(card).guidance}
+
+ );
+}
+
+const faceColors = {
+ weapon: 'from-[#512f28] via-[#302622] to-[#171b17]',
+ defense: 'from-[#284544] via-[#243330] to-[#171b17]',
+ leader: 'from-[#4b3c23] via-[#353123] to-[#171b17]',
+ utility: 'from-[#38344b] via-[#2d2c34] to-[#171b17]',
+};
+const roleIcons = {
+ weapon: Swords,
+ defense: Shield,
+ leader: UserRound,
+ utility: Sparkles,
+};
+
+/** Read-only inspection. It cannot play a card, read game state, or reveal another card. */
+export function CardInspector({ card }: CardProps) {
+ const presentation = cardPresentation(card);
+ const Icon = roleIcons[presentation.role];
+ return (
+
+
+ }
+ aria-label={`Inspect card: ${card.name}`}
+ >
+
+ Inspect card
+
+
+
+
+
+
+ Card inspection
+
+
+ }
+ >
+
+ Close
+
+
+
+
+
+
+
+ {presentation.category}
+
+
+
+
+
+
+
+ {card.name}
+
+
+ {presentation.guidance}
+
+
+ Treachery · gameplay guide
+
+
+
+
+ {presentation.availability ??
+ 'Inspecting a card does not play it. Close this view to use the table’s action controls.'}
+
+ {presentation.gameplay && (
+
+
+ Playing {card.name}
+
+ {presentation.gameplay.map((paragraph) => (
+
+ {paragraph}
+
+ ))}
+
+ )}
+ {presentation.topics.map((topic, index) => (
+
+
+ {topic.title}
+
+
+ {topic.steps.map((step) => (
+
+ {step}
+
+ ))}
+ {topic.example && (
+
+ Example:
+ {topic.example}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/components/choam-auditor.tsx b/components/choam-auditor.tsx
new file mode 100644
index 00000000..48a97f05
--- /dev/null
+++ b/components/choam-auditor.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { Button } from '@/components/ui/button';
+import { CardInspector, CardRules } from './card-inspector';
+
+export function AuditorDecision({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const audit = game.auditor;
+ if (!audit) return null;
+ const payment = game.decision?.kind === 'choamAuditPayment';
+ const opponent = game.players.find((p) => p.id === audit.opponent);
+ return (
+
+
+ {payment ? 'Keep your hand private' : 'Use the Auditor'}
+
+
+ {payment
+ ? `Pay ${audit.count} spice to CHOAM to cancel the entire inspection, or allow it without payment.`
+ : `Your Auditor ${audit.survived ? 'survived' : 'died'}. You may inspect ${audit.count} random card${audit.count === 1 ? '' : 's'} from ${opponent?.name ?? 'your opponent'}’s hand. Cards used in this battle are excluded.`}
+
+ {!payment && (
+
+ Karama can prevent the audit. Your opponent can also cancel by paying
+ one spice per card you would inspect.
+
+ )}
+
+
+ act(
+ payment
+ ? {
+ type: 'decision',
+ event: audit.event,
+ pay: true,
+ count: audit.count,
+ }
+ : { type: 'decision', event: audit.event, audit: true },
+ )
+ }
+ >
+ {payment
+ ? `Pay ${audit.count} spice · cancel audit`
+ : 'Audit opposing hand'}
+
+
+ act(
+ payment
+ ? { type: 'decision', event: audit.event, pay: false }
+ : { type: 'decision', event: audit.event, audit: false },
+ )
+ }
+ >
+ {payment ? 'Allow inspection' : 'Decline audit'}
+
+
+
+ );
+}
+
+export function AuditorInsight({ game }: { game: GameView }) {
+ const insight = game.auditorInsight;
+ if (!insight) return null;
+ const opponent = game.players.find((p) => p.id === insight.target);
+ return (
+
+
+ Your Auditor inspection · {opponent?.name}
+
+
+ Only you receive this inspection. These cards were in the opposing hand
+ when the audit resolved; they may move afterward. The inspection stays
+ here until the next battle or turn.
+
+
+ {insight.cards.map((card) => (
+
+ {card.name}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/choam-baliset.tsx b/components/choam-baliset.tsx
new file mode 100644
index 00000000..a0370731
--- /dev/null
+++ b/components/choam-baliset.tsx
@@ -0,0 +1,112 @@
+'use client';
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { HelpTip } from './help-tip';
+import type { Action, GameView } from '@/game/engine';
+import { gameTerritories } from '@/game/board';
+
+export function ChoamBaliset({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (a: Action) => void;
+ busy: boolean;
+}) {
+ const [target, setTarget] = useState('');
+ const [territory, setTerritory] = useState('');
+ if (!g.choamWorthless || g.phase !== 5) return null;
+ const d = g.decision?.kind === 'choamMovement' ? g.decision : null;
+ const card = g.choamWorthless.cards.find((c) => c.name === 'Baliset');
+ if (!card && !d) return null;
+ const players = g.players.filter((p) => p.faction !== 'choam');
+ const selectedPlayer =
+ d?.mover ?? players.find((p) => p.id === target)?.id ?? players[0]?.id;
+ const territories = g.choamWorthless.baliset.filter(
+ (t) =>
+ !g.balisetRestrictions.some(
+ (b) => b.territory === t && b.player === selectedPlayer,
+ ),
+ );
+ const selectedTerritory =
+ d?.territory ??
+ (territories.includes(territory) ? territory : territories[0]);
+ const name = (id?: string) =>
+ gameTerritories(g).find((t) => t.id === id)?.name;
+ return (
+
+
+ Baliset
+
+
+ Prevent a player from moving into one territory you occupy this phase.
+ Shipment remains possible. Karama may cancel the effect.
+
+ {d ? (
+
+ {g.players.find((p) => p.id === d.mover)?.name} has declared a move of{' '}
+ {d.amount} forces into {name(d.territory)}, sector {d.sector}. No
+ forces have moved yet.
+
+ ) : (
+ card && (
+ <>
+
+ Player{' '}
+ setTarget(e.target.value)}
+ >
+ {players.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+
+ Territory{' '}
+ setTerritory(e.target.value)}
+ >
+ {territories.map((t) => (
+
+ {name(t)}
+
+ ))}
+
+
+ >
+ )
+ )}
+ {card && (
+
+ act({
+ type: 'card',
+ mode: 'choam',
+ card: card.id,
+ target: selectedPlayer,
+ territory: selectedTerritory,
+ })
+ }
+ >
+ Prevent movement
+
+ )}
+ {!card && d && No available Baliset play.
}
+ {d && (
+ act({ type: 'decision', decline: true })}
+ >
+ Allow movement
+
+ )}
+
+ );
+}
diff --git a/components/choam-battle-funding.tsx b/components/choam-battle-funding.tsx
new file mode 100644
index 00000000..b4afad02
--- /dev/null
+++ b/components/choam-battle-funding.tsx
@@ -0,0 +1,66 @@
+'use client';
+import { useState } from 'react';
+import { HelpTip } from './help-tip';
+import { Button } from '@/components/ui/button';
+import type { Action, GameView } from '@/game/engine';
+export function ChoamBattleFunding({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (a: Action) => void;
+ busy: boolean;
+}) {
+ const me = g.players.find((p) => p.id === g.me)!;
+ const [amount, setAmount] = useState(g.aid.pledged);
+ const deciding = g.decision?.kind === 'choamBattleFunding';
+ if (!g.advanced || g.phase !== 6 || !me.ally || me.faction !== 'choam')
+ return null;
+ return (
+
+
+ Fund your ally’s battle forces
+
+
+ Set aside spice for your ally’s combat support. Your ally chooses how
+ much to use with each plan. Sealed payments stay reserved; unspent funds
+ return at the end of Battle.
+
+ Your current unspent pledge: {g.aid.pledged} spice.
+ {g.battle?.choamAidBlocked && (
+
+ Karama prevents using your funding in this battle. Your pledge remains
+ available for later battles.
+
+ )}
+
+ New unspent pledge{' '}
+ setAmount(Number(e.target.value))}
+ />
+
+
+ act({ type: deciding ? 'decision' : 'pledgeAid', amount })
+ }
+ >
+ {deciding ? 'Confirm battle funding' : 'Update battle funding'}
+
+ {deciding && (
+ act({ type: 'decision', amount: g.aid.pledged })}
+ >
+ Keep current pledge
+
+ )}
+
+ );
+}
diff --git a/components/choam-cash-in.tsx b/components/choam-cash-in.tsx
new file mode 100644
index 00000000..cb6a8a00
--- /dev/null
+++ b/components/choam-cash-in.tsx
@@ -0,0 +1,66 @@
+'use client';
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import type { Action, GameView } from '@/game/engine';
+export function ChoamCashIn({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (a: Action) => void;
+ busy: boolean;
+}) {
+ const [activation, setActivation] = useState('');
+ const [selected, setSelected] = useState([]);
+ const cash = g.choamCashIn;
+ if (!cash?.karamas.length) return null;
+ const card = cash.karamas.find((c) => c.id === activation) ?? cash.karamas[0];
+ const choices = cash.cards.filter((c) => c.id !== card.id);
+ const cards = selected.filter((id) => choices.some((c) => c.id === id));
+ return (
+
+ );
+}
diff --git a/components/choam-gamont.tsx b/components/choam-gamont.tsx
new file mode 100644
index 00000000..709f0cd9
--- /dev/null
+++ b/components/choam-gamont.tsx
@@ -0,0 +1,98 @@
+'use client';
+import { useState } from 'react';
+import { HelpTip } from './help-tip';
+import { Button } from '@/components/ui/button';
+import type { Action, GameView } from '@/game/engine';
+import { gameTerritories, splitLocation } from '@/game/board';
+export function ChoamGamont({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (a: Action) => void;
+ busy: boolean;
+}) {
+ const [selection, setSelection] = useState('');
+ if (!g.choamWorthless || g.phase !== 8) return null;
+ const card = g.choamWorthless.cards.find((c) => c.name === 'Trip to Gamont');
+ const options = g.choamWorthless.gamont;
+ const key = (o: (typeof options)[number]) =>
+ `${o.target}/${o.key}/${o.elite}`;
+ const chosen = options.find((o) => key(o) === selection) ?? options[0];
+ return (
+
+
+ Trip to Gamont
+
+
+ Return one other player’s force to its reserves. Choose its sector and
+ force type. Karama may stop the return; victory is checked after this
+ opportunity. A No-Field in the selected sector must reveal first; the
+ card is used even if no force is there to return.
+
+ {card && options.length > 0 && (
+ <>
+
+ Force to return{' '}
+ setSelection(e.target.value)}
+ >
+ {options.map((o) => {
+ const loc = splitLocation(o.key),
+ owner = g.players.find((p) => p.id === o.target)!;
+ const type = o.noField
+ ? 'No-Field — reveal, then return one if present'
+ : o.elite
+ ? owner.faction === 'ixians'
+ ? 'cyborg'
+ : 'elite'
+ : owner.faction === 'ixians'
+ ? 'suboid'
+ : 'ordinary';
+ return (
+
+ {owner.name} ·{' '}
+ {
+ gameTerritories(g).find((t) => t.id === loc.territory)
+ ?.name
+ }{' '}
+ · sector {loc.sector} · {type}
+
+ );
+ })}
+
+
+
+ act({
+ type: 'card',
+ mode: 'choam',
+ card: card.id,
+ target: chosen.target,
+ from: chosen.key,
+ elite: chosen.elite,
+ })
+ }
+ >
+ Return this force
+
+ >
+ )}
+ {(!card || !options.length) && (
+ No available Trip to Gamont play.
+ )}
+ {g.decision?.kind === 'choamMentat' && (
+ act({ type: 'decision', done: true })}
+ >
+ Finish and check victory
+
+ )}
+
+ );
+}
diff --git a/components/choam-market.tsx b/components/choam-market.tsx
new file mode 100644
index 00000000..a21bb796
--- /dev/null
+++ b/components/choam-market.tsx
@@ -0,0 +1,131 @@
+'use client';
+import type { Action, GameView } from '@/game/engine';
+import { Button } from '@/components/ui/button';
+export function ChoamMarket({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const me = g.players.find((p) => p.id === g.me)!;
+ const market = g.choamMarket!;
+ if (g.decision?.kind === 'choamTradeReply')
+ return (
+ <>
+
+ CHOAM offers{' '}
+
+ {market.offered?.name ?? 'a card no longer available'}
+
+ . Choose one card to return. CHOAM will confirm before either hand
+ changes.
+
+ {me.hand?.map((c) => (
+ act({ type: 'decision', card: c.id })}
+ >
+ Return {c.name}
+
+ ))}
+ act({ type: 'decision', decline: true })}
+ >
+ Decline trade
+
+ >
+ );
+ if (g.decision?.kind === 'choamTradeConfirm')
+ return (
+ <>
+
+ Give {market.offered?.name ?? 'an unavailable card'} {' '}
+ and receive{' '}
+ {market.returned?.name ?? 'an unavailable card'} .
+ Both cards move together. This uses your one trade for the turn.
+
+ act({ type: 'decision', accept: true })}
+ >
+ Confirm exchange
+
+ act({ type: 'decision', decline: true })}
+ >
+ Decline trade
+
+ >
+ );
+ return (
+ <>
+
+ The phase is ending. Sell a Worthless card for two spice or a surplus
+ exact duplicate for three. A sale reveals the card and gives the table a
+ Karama response.
+
+ {market.sales?.map((sale) => (
+
+ act({
+ type: 'decision',
+ mode: 'sell',
+ card: sale.card,
+ witness: sale.witness,
+ })
+ }
+ >
+ Sell {sale.witness ? 'surplus ' : ''}
+ {me.hand?.find((c) => c.id === sale.card)?.name} · {sale.price} spice
+
+ ))}
+ {!market.sales?.length && (
+ No eligible sales remain this phase.
+ )}
+ {market.canTrade && (
+ <>
+ Offer your ally a card
+
+ Your ally chooses a return card; you confirm the exchange. Offers
+ stay private to the two of you.
+
+ {me.hand?.map((c) => (
+
+ act({ type: 'decision', mode: 'trade', card: c.id })
+ }
+ >
+ Offer {c.name}
+
+ ))}
+ >
+ )}
+ act({ type: 'decision', done: true })}
+ >
+ Finish this phase
+
+ >
+ );
+}
diff --git a/components/choam-storm.tsx b/components/choam-storm.tsx
new file mode 100644
index 00000000..422814f2
--- /dev/null
+++ b/components/choam-storm.tsx
@@ -0,0 +1,83 @@
+'use client';
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { HelpTip } from './help-tip';
+import type { Action, GameView } from '@/game/engine';
+import { territory } from '@/game/board';
+
+export function ChoamStorm({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (a: Action) => void;
+ busy: boolean;
+}) {
+ const [selected, setSelected] = useState('');
+ const decision = g.decision?.kind === 'choamStorm' ? g.decision : null;
+ if (!decision || decision.player !== g.me) return null;
+ const card = g.choamWorthless?.cards.find((c) => c.name === 'Jubba Cloak');
+ const choice =
+ decision.territories.find((t) => t.territory === selected) ??
+ decision.territories[0];
+ return (
+
+
+ Jubba Cloak
+
+
+ Protect your own forces in one territory from this storm movement. Spice
+ and other factions’ forces remain exposed. Karama may cancel this
+ effect.
+
+ {decision.protected.length > 0 && (
+
+ Protected:{' '}
+ {decision.protected.map((t) => territory(t).name).join(', ')}.
+
+ )}
+
+ Threatened territory{' '}
+ setSelected(e.target.value)}
+ >
+ {decision.territories.map((t) => (
+
+ {territory(t.territory).name} · {t.amount} forces · sectors{' '}
+ {t.sectors.join(', ')}
+
+ ))}
+
+
+ {card ? (
+
+ act({
+ type: 'card',
+ mode: 'choam',
+ card: card.id,
+ territory: choice.territory,
+ })
+ }
+ >
+ Protect this territory
+
+ ) : (
+
+ No available Jubba Cloak play. Continue when ready to accept the
+ remaining losses.
+
+ )}
+ act({ type: 'decision', decline: true })}
+ >
+ Continue storm
+
+
+ );
+}
diff --git a/components/choam-worthless.tsx b/components/choam-worthless.tsx
new file mode 100644
index 00000000..14d99b07
--- /dev/null
+++ b/components/choam-worthless.tsx
@@ -0,0 +1,93 @@
+'use client';
+import { useState } from 'react';
+import { HelpTip } from './help-tip';
+import { Button } from '@/components/ui/button';
+import type { Action, GameView } from '@/game/engine';
+export function ChoamWorthless({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (a: Action) => void;
+ busy: boolean;
+}) {
+ const [target, setTarget] = useState('');
+ const me = g.players.find((p) => p.id === g.me)!;
+ const reactive = g.decision?.kind === 'choamFreeRevival';
+ if (!g.choamWorthless || ![4, 5].includes(g.phase)) return null;
+ const selected = g.choamWorthless.targets.includes(target)
+ ? target
+ : g.choamWorthless.targets[0];
+ const recipient =
+ g.decision?.kind === 'choamFreeRevival' ? g.decision.recipient : selected;
+ return (
+
+
+ CHOAM Worthless powers
+
+
+ {g.phase === 4
+ ? 'La La La prevents a player’s free force revivals for the rest of Revival. If it stops a pending request, the player can choose a paid revival.'
+ : 'Kulon adds one territory to your movement range this turn, including movement with ornithopters.'}{' '}
+ A Karama response precedes discarding the card.
+
+ {g.phase === 4 && !reactive && (
+
+ Player to deny free revival{' '}
+ setTarget(e.target.value)}
+ >
+ {g.choamWorthless.targets.map((id) => (
+
+ {g.players.find((p) => p.id === id)?.name}
+
+ ))}
+
+
+ )}
+ {reactive && (
+
+ {g.players.find((p) => p.id === recipient)?.name} is requesting free
+ force revival.
+
+ )}
+ {g.choamWorthless.cards
+ .filter((c) =>
+ g.phase === 4 ? c.name === 'La La La' : c.name === 'Kulon',
+ )
+ .map((c) => (
+ = (me.movesAllowed ?? 1))) ||
+ (g.phase === 4 && !recipient)
+ }
+ onClick={() =>
+ act({
+ type: 'card',
+ mode: 'choam',
+ card: c.id,
+ target: recipient,
+ })
+ }
+ >
+ Use {c.name}
+
+ ))}
+ {reactive && (
+ act({ type: 'decision', decline: true })}
+ >
+ Allow this free revival
+
+ )}
+
+ );
+}
diff --git a/components/distrans.tsx b/components/distrans.tsx
new file mode 100644
index 00000000..04501a59
--- /dev/null
+++ b/components/distrans.tsx
@@ -0,0 +1,145 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { Button } from './ui/button';
+import { CardInspector, CardRules } from './card-inspector';
+
+export function Distrans({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const [target, setTarget] = useState('');
+ const [selection, setSelection] = useState('');
+ const distrans = game.distrans;
+ if (!distrans) return null;
+ const choice =
+ distrans.choices.find((candidate) => candidate.recipient === target) ??
+ distrans.choices.find(
+ (candidate) => !candidate.blocked && candidate.cards.length > 0,
+ ) ??
+ distrans.choices[0];
+ const recipient = game.players.find(
+ (player) => player.id === choice?.recipient,
+ );
+ const given =
+ choice?.cards.find((card) => card.id === selection) ?? choice?.cards[0];
+ const reason =
+ distrans.blocked ??
+ choice?.blocked ??
+ (!choice
+ ? 'No recipient is available.'
+ : !given
+ ? 'No other card is available to transfer to this player.'
+ : null);
+ return (
+
+
+ Distrans: transfer a card
+
+
+
+ Give one other card from your hand to another player with room, then
+ discard Distrans. No spice is paid. The transfer happens immediately,
+ without a recipient confirmation or Karama response.
+
+
+ {choice && (
+ <>
+
Recipient
+
{
+ setTarget(event.target.value);
+ setSelection('');
+ }}
+ >
+ {distrans.choices.map((candidate) => (
+
+ {game.players.find(
+ (player) => player.id === candidate.recipient,
+ )?.name ?? 'Player'}
+ {candidate.blocked ? ' — unavailable' : ''}
+
+ ))}
+
+ {choice.cards.length > 0 && (
+ <>
+
Your card to transfer
+
setSelection(event.target.value)}
+ >
+ {choice.cards.map((card) => (
+
+ {card.name}
+
+ ))}
+
+ {given && (
+
+
+
+
+ )}
+ >
+ )}
+ >
+ )}
+ {reason && (
+
+ {reason}
+
+ )}
+
{
+ if (!busy && !reason && choice && given)
+ act({
+ type: 'card',
+ card: distrans.card.id,
+ target: choice.recipient,
+ give: given.id,
+ });
+ }}
+ >
+ Give {given?.name ?? 'a card'} to {recipient?.name ?? 'the recipient'}{' '}
+ and discard Distrans
+
+ {!!choice?.unavailable.length && (
+
+
+ Your cards unavailable for this transfer
+
+
+ {choice.unavailable.map(({ card, reason: blocked }) => (
+
+ {card.name}
+ {blocked}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/components/duke-vidal.tsx b/components/duke-vidal.tsx
new file mode 100644
index 00000000..9a339531
--- /dev/null
+++ b/components/duke-vidal.tsx
@@ -0,0 +1,241 @@
+'use client';
+
+import { useId, useState } from 'react';
+import Image from 'next/image';
+import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
+import { Eye, X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogDescription,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import type { GameView } from '@/game/engine';
+import { DUKE_VIDAL_RULES } from '@/game/duke-vidal';
+import { specialLeaderArt } from '@/game/special-leader-art';
+
+/** Original Ecaz medallion; identity and strength have equivalent HTML text. */
+function VidalDisc({ large = false }: { large?: boolean }) {
+ const gradient = useId();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 6
+
+
+ );
+}
+
+/** Uses only the shared disc identity supplied by the public projection. */
+function VidalPortrait({
+ id,
+ name,
+ large = false,
+}: {
+ id?: string;
+ name: string;
+ large?: boolean;
+}) {
+ const [failedSource, setFailedSource] = useState(null);
+ const art = specialLeaderArt(id, name);
+ if (!art || failedSource === art.src) return ;
+ return (
+
+
+ setFailedSource(art.src)}
+ className="h-full w-full object-cover"
+ style={{ objectPosition: art.objectPosition }}
+ />
+
+
+
+ );
+}
+
+/** Read-only shared component. Custody comes exclusively from the public projection. */
+export function DukeVidal({ game }: { game: GameView }) {
+ const duke = game.dukeVidal;
+ if (!duke) return null;
+ const controller = duke.controller
+ ? game.players.find((player) => player.id === duke.controller)
+ : undefined;
+ const status = duke.leader.dead
+ ? 'In the Tanks'
+ : duke.controller
+ ? `Controlled by ${controller?.name ?? 'another player'}`
+ : 'Set aside';
+ return (
+
+
+
+
+
+ Ecaz · shared leader · strength 6
+
+
+ Duke Prad Vidal
+
+
{status}
+
No Traitor Card
+
+
+
+ }
+ aria-label="Inspect Duke Prad Vidal"
+ >
+ Inspect Duke
+
+
+
+
+
+
+ Shared leader inspection
+
+
+ }
+ >
+ Close
+
+
+
+
+
+
+ House Ecaz
+
+
+
+ {DUKE_VIDAL_RULES.name}
+
+
+
+
+ Leader strength
+
+ 6
+
+
+
+ Current state
+
+ {status}
+
+
+
+ One shared disc. No Traitor Card.
+
+
+
+
+ {DUKE_VIDAL_RULES.summary}
+
+ {DUKE_VIDAL_RULES.gameplay.map((paragraph) => (
+
+ {paragraph}
+
+ ))}
+
+ Current support covers Moritani acquisition, direct Ecaz
+ Ambassador acquisition for Ecaz, battle use and death.
+ Unused Ecaz custody survives the turn; unused Moritani
+ custody expires. Ecaz alliance and loan alternatives,
+ reacquisition while already controlled, revival, and
+ captured or ghola custody remain unfinished. Full Ecaz and
+ Moritani games are unavailable. Inspection does not select
+ a battle leader.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/ecaz-ambassadors.tsx b/components/ecaz-ambassadors.tsx
new file mode 100644
index 00000000..07c3e663
--- /dev/null
+++ b/components/ecaz-ambassadors.tsx
@@ -0,0 +1,340 @@
+'use client';
+
+import { useId, useState } from 'react';
+import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
+import { Eye, X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogDescription,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import type { Action, GameView } from '@/game/engine';
+import type { AmbassadorToken } from '@/game/ecaz-ambassadors';
+import {
+ AMBASSADOR_COMMON_GUIDANCE,
+ AMBASSADOR_REFERENCE,
+} from '@/game/ambassador-reference';
+import { TERRITORIES } from '@/game/board';
+import { faction } from '@/game/catalog';
+
+export const AMBASSADOR_COVERAGE =
+ 'Development coverage: Ambassador placement, Ecaz’s direct Duke acquisition, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are supported, plus Bene Gesserit copies of those eight ordinary effects. Ecaz alliance and loan choices, other effects and some competing arrival reactions remain unfinished; full Ecaz starts remain disabled.';
+function tokenLocation(token: AmbassadorToken) {
+ if (token.zone === 'placed')
+ return `Placed in ${TERRITORIES.find((t) => t.id === token.location)?.name ?? 'a stronghold'}`;
+ return {
+ pool: 'Unused pool',
+ supply: 'Available supply',
+ used: 'Triggered · set aside',
+ removed: 'Permanently removed',
+ }[token.zone];
+}
+function AmbassadorArt({
+ token,
+ large = false,
+}: {
+ token: AmbassadorToken;
+ large?: boolean;
+}) {
+ const identity = faction(token.effect);
+ return (
+
+
+
+
+
+ {identity.sigil}
+
+
+ );
+}
+/** Inspection accepts one already-public token; no private deck or game state is consulted. */
+export function AmbassadorInspector({ token }: { token: AmbassadorToken }) {
+ const guide = AMBASSADOR_REFERENCE[token.effect];
+ const status = tokenLocation(token);
+ return (
+
+
+ }
+ aria-label={`Inspect ${guide.name} Ambassador: ${status}`}
+ >
+
+
+ {guide.name}
+ {status}
+
+
+
+
+
+
+
+
+
+ {guide.name} Ambassador
+
+
+ Gameplay guide · {status}
+
+
+
+ }
+ aria-label="Close Ambassador inspection"
+ >
+
+
+
+
+ {guide.gameplay}
+
+ Using Ambassadors
+
+ {AMBASSADOR_COMMON_GUIDANCE.map((paragraph) => (
+
+ {paragraph}
+
+ ))}
+
+ {AMBASSADOR_COVERAGE}
+
+
+ Original illustration and gameplay explanation.
+
+
+
+
+ );
+}
+
+export function AmbassadorSupply({ game }: { game: GameView }) {
+ if (!game.ambassadors) return null;
+ const supplyCount = game.ambassadors.tokens.filter(
+ (token) => token.zone === 'supply',
+ ).length;
+ const placedCount = game.ambassadors.tokens.filter(
+ (token) => token.zone === 'placed',
+ ).length;
+ const zones: AmbassadorToken['zone'][] = [
+ 'supply',
+ 'placed',
+ 'used',
+ 'removed',
+ 'pool',
+ ];
+ const names = {
+ supply: 'Available supply',
+ placed: 'On the board',
+ used: 'Triggered this group',
+ removed: 'Permanently removed',
+ pool: 'Unused pool',
+ };
+ return (
+
+
+ Ecaz Ambassadors {' '}
+
+ · {supplyCount} in supply · {placedCount} placed · Inspect tokens
+
+
+
+ Inspect any token to read its effect and current location.
+
+ {zones.map((zone) => {
+ const tokens = game.ambassadors!.tokens.filter(
+ (token) => token.zone === zone,
+ );
+ if (!tokens.length) return null;
+ return (
+
+
+ {names[zone]} · {tokens.length}
+
+
+ {tokens.map((token) => (
+
+ ))}
+
+
+ );
+ })}
+ {AMBASSADOR_COVERAGE}
+
+ );
+}
+
+export function EcazPlacement({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const [selectedToken, setSelectedToken] = useState('');
+ const [selectedTerritory, setSelectedTerritory] = useState('');
+ const me = game.players.find((player) => player.id === game.me);
+ const state = game.ambassadors;
+ if (
+ !state ||
+ me?.faction !== 'ecaz' ||
+ game.decision?.kind !== 'ecazPlacement' ||
+ game.decision.player !== me.id
+ )
+ return null;
+ const supply = state.tokens.filter((token) => token.zone === 'supply');
+ const destinations = TERRITORIES.filter(
+ (territory) =>
+ territory.type === 'stronghold' &&
+ !territory.sectors.includes(game.storm) &&
+ !state.tokens.some(
+ (token) => token.zone === 'placed' && token.location === territory.id,
+ ),
+ );
+ const token = supply.find((candidate) => candidate.id === selectedToken);
+ const destination = destinations.find(
+ (candidate) => candidate.id === selectedTerritory,
+ );
+ const affordable = (me.spice ?? 0) >= state.nextCost;
+ const disabled =
+ busy || state.blocked || !token || !destination || !affordable;
+ return (
+
+ Place an Ambassador
+
+ Next placement: {state.nextCost} spice · Your available
+ spice: {me.spice ?? 0}. Each placement this turn increases the next cost
+ by one.
+
+
+ Ambassador
+ setSelectedToken(event.target.value)}
+ >
+ Choose from your supply
+ {supply.map((candidate) => (
+
+ {AMBASSADOR_REFERENCE[candidate.effect].name}
+
+ ))}
+
+
+ {token && (
+ <>
+
+ {AMBASSADOR_REFERENCE[token.effect].gameplay}
+
+
+ >
+ )}
+
+ Stronghold
+ setSelectedTerritory(event.target.value)}
+ >
+ Choose a stronghold outside the storm
+ {destinations.map((candidate) => (
+
+ {candidate.name}
+
+ ))}
+
+
+ {state.blocked && (
+
+ Ambassador placement is blocked for this turn.
+
+ )}
+ {!supply.length && (
+
+ No Ambassador is available in your supply.
+
+ )}
+ {!destinations.length && (
+
+ No ordinary stronghold is currently available for an Ambassador.
+
+ )}
+ {!affordable && (
+
+ You need {state.nextCost - (me.spice ?? 0)} more spice for this
+ placement.
+
+ )}
+
+ {
+ if (!disabled && token && destination)
+ act({
+ type: 'decision',
+ token: token.id,
+ territory: destination.id,
+ });
+ }}
+ >
+ Place Ambassador · {state.nextCost} spice
+
+ act({ type: 'decision', decline: true })}
+ >
+ Finish Ambassador placement
+
+
+ {AMBASSADOR_COVERAGE}
+
+ );
+}
diff --git a/components/ecaz-entry.tsx b/components/ecaz-entry.tsx
new file mode 100644
index 00000000..3b7c5045
--- /dev/null
+++ b/components/ecaz-entry.tsx
@@ -0,0 +1,367 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { AMBASSADOR_REFERENCE } from '@/game/ambassador-reference';
+import { territory } from '@/game/board';
+import { faction } from '@/game/catalog';
+import { leaderStrengthLabel } from '@/game/cards';
+import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
+import { Button } from './ui/button';
+import { CardInspector, CardRules } from './card-inspector';
+import { LeaderInspector } from './leader-inspector';
+import { AmbassadorInspector, AMBASSADOR_COVERAGE } from './ecaz-ambassadors';
+import { AmbassadorMovement } from './ambassador-movement';
+import { AmbassadorShipment } from './ambassador-shipment';
+
+const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
+
+export function EcazEntry({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const [selected, setSelected] = useState([]);
+ const entry = game.ambassadorEntry;
+ if (
+ !entry ||
+ game.decision?.kind !== 'ecazAmbassador' ||
+ game.decision.player !== game.me ||
+ !['offer', 'copy', 'cards', 'move', 'ship'].includes(entry.stage)
+ )
+ return null;
+ const me = game.players.find((p) => p.id === game.me)!;
+ const guide = AMBASSADOR_REFERENCE[entry.effect];
+ const token = game.ambassadors?.tokens.find((t) => t.effect === entry.effect);
+ const send = (fields: Omit) => {
+ if (!busy) act({ type: 'decision', event: entry.event, ...fields });
+ };
+ const choices = entry.cards.flatMap((option) => {
+ const card = me.hand?.find((card) => card.id === option.card);
+ return card ? [{ card, blocked: option.blocked }] : [];
+ });
+ const chosen = choices.filter(
+ (choice) => !choice.blocked && selected.includes(choice.card.id),
+ );
+ const legalSelection = entry.effect === 'choam' || chosen.length === 1;
+ const dukeBlock =
+ entry.dukeAcquisition?.blocked ??
+ (entry.dukeAcquisition
+ ? null
+ : 'Duke acquisition is unavailable for this opportunity.');
+ const duke = game.dukeVidal?.leader;
+ return (
+
+
+ {guide.name} Ambassador · {territory(entry.territory).name}
+
+
+ {game.players.find((p) => p.id === entry.entrant)?.name} entered this
+ stronghold. {guide.gameplay}
+
+ {token && }
+ {entry.stage === 'offer' ? (
+ <>
+ {entry.effect === 'ecaz' ? (
+ <>
+
+ Acquire Duke Vidal for Ecaz. The Ambassador returns to your
+ supply. Ecaz keeps Duke until battle use or until Moritani takes
+ him.
+
+ {duke && (
+
+ )}
+ {
+ if (!dukeBlock)
+ send({
+ trigger: true,
+ beneficiary: entry.owner,
+ choice: 'duke',
+ });
+ }}
+ >
+ Acquire Duke Vidal for Ecaz
+
+ {dukeBlock && (
+
+ {dukeBlock}
+
+ )}
+
+ Alliance proposals, acquisition for an ally and Duke loans
+ through this Ambassador remain unfinished.
+
+ >
+ ) : (
+ <>
+
+ Trigger the Ambassador for yourself or an eligible ally, or
+ leave it in place. Triggering commits the token before the
+ beneficiary resolves its effect.
+
+ {entry.effect === 'richese' && (
+
+ Triggering automatically spends 3 spice from the selected
+ beneficiary and draws a card if the purchase can be completed.
+ Otherwise no spice is spent and no card is drawn. The token is
+ used either way; there is no later purchase choice.
+
+ )}
+ {entry.beneficiaries.map((option) => (
+
+
+ send({ trigger: true, beneficiary: option.player })
+ }
+ >
+ Trigger for{' '}
+ {option.player === game.me
+ ? 'yourself'
+ : game.players.find((p) => p.id === option.player)?.name}
+
+ {option.blocked && (
+
{option.blocked}
+ )}
+
+ ))}
+ >
+ )}
+ send({ decline: true })}
+ >
+ Leave Ambassador in place
+
+ >
+ ) : entry.stage === 'copy' ? (
+ <>
+
+ Choose an effect outside the original supply for this group. The
+ Bene Gesserit token has already been permanently removed.
+
+ {entry.copies.map((option) => {
+ const copied = AMBASSADOR_REFERENCE[option.effect];
+ const face = game.ambassadors?.tokens.find(
+ (t) => t.effect === option.effect,
+ );
+ return (
+
+ {copied.name}
+ {copied.gameplay}
+ {option.effect === 'richese' && (
+
+ This automatically spends 3 of your spice and draws a card
+ if the purchase can be completed. Otherwise no spice is
+ spent and no card is drawn. The Bene Gesserit token remains
+ removed.
+
+ )}
+ {face && }
+ {option.blocked && (
+ {option.blocked}
+ )}
+ send({ effect: option.effect })}
+ >
+ Use {copied.name} effect
+
+
+ );
+ })}
+ >
+ ) : entry.stage === 'ship' ? (
+
+ ) : entry.stage === 'move' ? (
+
+ ) : (
+ <>
+
+ {entry.effect === 'choam'
+ ? 'Choose any number of available cards, including none. Each discarded card earns three spice.'
+ : 'Choose exactly one available card to discard and draw its replacement.'}
+
+
+
+ Your cards available for this effect
+
+ {choices.map(({ card, blocked }) => (
+
+
+
+ setSelected((current) =>
+ entry.effect === 'ixians'
+ ? [card.id]
+ : event.target.checked
+ ? [...current, card.id]
+ : current.filter((value) => value !== card.id),
+ )
+ }
+ />
+ {card.name}
+
+
+
+ {blocked && {blocked}
}
+
+ ))}
+
+ {
+ if (legalSelection)
+ send({ cards: chosen.map((choice) => choice.card.id) });
+ }}
+ >
+ {entry.effect === 'choam'
+ ? `Discard ${chosen.length} cards · gain ${chosen.length * 3} spice`
+ : 'Discard selected card and draw replacement'}
+
+ >
+ )}
+ {AMBASSADOR_COVERAGE}
+
+ );
+}
+
+/** Only recipient-projected snapshots are read. Local disclosures never send a game action. */
+export function AmbassadorInsights({ game }: { game: GameView }) {
+ const snapshots = game.ambassadorInsights.filter(
+ (entry) => entry.viewer === game.me,
+ );
+ if (!snapshots.length) return null;
+ return (
+
+ Private Ambassador inspections
+
+ These are snapshots of what you inspected at that moment, not a live
+ view of another hand. Only you receive them. Opening or closing an
+ inspection does not take a game action.
+
+ {snapshots.map((snapshot, index) => {
+ const target = game.players.find((p) => p.id === snapshot.target);
+ const leader = game.allLeaders.find((l) => l.id === snapshot.traitor);
+ const cheapHero = snapshot.traitor === CHEAP_HERO_TRAITOR;
+ return (
+
+
+ Turn {snapshot.turn} · {target?.name ?? 'Player'} ·{' '}
+ {snapshot.effect === 'atreides'
+ ? 'Treachery hand'
+ : target?.faction === 'tleilaxu'
+ ? 'Face Dancer identity'
+ : 'Traitor identity'}
+
+ {snapshot.effect === 'atreides' ? (
+
+ {!snapshot.cards.length && (
+
+ The inspected hand was empty.
+
+ )}
+ {snapshot.cards.map((card) => (
+
+ {card.name}
+
+
+
+ ))}
+
+ ) : cheapHero || leader ? (
+
+
+
+ ) : (
+
+ Recorded identity: {snapshot.traitor ?? 'None'}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/components/ecaz-spice.tsx b/components/ecaz-spice.tsx
new file mode 100644
index 00000000..7e6c8bce
--- /dev/null
+++ b/components/ecaz-spice.tsx
@@ -0,0 +1,149 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { AllocationState } from '@/game/ecaz-spice-allocation';
+import { territory } from '@/game/board';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+
+const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
+
+/** Parent key: event + allocation.index + allocation.player. A new lot or
+ * negotiating turn starts with a fresh draft rather than an old proposal. */
+export function EcazSpice({
+ event,
+ allocation,
+ me,
+ players,
+ send,
+ busy,
+}: {
+ event: string;
+ allocation: AllocationState;
+ me: string;
+ players: readonly { id: string; name: string }[];
+ send: (action: { type: string; [key: string]: unknown }) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const lot = allocation.lots[allocation.index];
+ const [draft, setDraft] = useState(() =>
+ String(Math.floor((lot?.amount ?? 0) / 2)),
+ );
+ if (!lot || allocation.player !== me) return null;
+ const name = (player: string) =>
+ players.find((p) => p.id === player)?.name ?? player;
+ const ecazName = name(lot.ecaz);
+ const allyName = name(lot.ally);
+ const ecazShare = Number(draft);
+ const valid =
+ draft.trim() !== '' &&
+ Number.isSafeInteger(ecazShare) &&
+ ecazShare >= 0 &&
+ ecazShare <= lot.amount;
+ const equalEcaz = Math.floor(lot.amount / 2);
+ const equalAlly = lot.amount - equalEcaz;
+ const offer = allocation.offer;
+ return (
+
+
+ Share spice from {territory(lot.territory).name}
+
+
+ Divide this shared pool of {lot.amount} spice between{' '}
+ {ecazName} (Ecaz{me === lot.ecaz ? ', you' : ''}) and {allyName} (ally
+ {me === lot.ally ? ', you' : ''}). This choice applies only to the
+ shared spice collected here.
+
+ {offer && (
+
+ {name(offer.by)} proposes {offer.ecazShare} spice for {ecazName} and{' '}
+ {lot.amount - offer.ecazShare} for {allyName}. Accept this split or
+ propose another.
+
+ )}
+
+
+ Equal fallback: {equalEcaz} spice for {ecazName}, {equalAlly} for{' '}
+ {allyName}. The ally receives any odd remainder.
+
+
+ send({ type: 'decision', event, allocation: { kind: 'equal' } })
+ }
+ >
+ Use equal split
+
+
+ );
+}
diff --git a/components/elite-count.tsx b/components/elite-count.tsx
new file mode 100644
index 00000000..1a12dc10
--- /dev/null
+++ b/components/elite-count.tsx
@@ -0,0 +1,29 @@
+'use client';
+import { useId } from 'react';
+import { Input } from '@/components/ui/input';
+export function EliteCount({
+ value,
+ onChange,
+ max,
+ label = 'Elite forces included',
+}: {
+ value: number;
+ onChange: (value: number) => void;
+ max: number;
+ label?: string;
+}) {
+ const id = useId();
+ return (
+
+ {label}
+ onChange(Number(e.target.value))}
+ />
+
+ );
+}
diff --git a/components/face-dance-decision.tsx b/components/face-dance-decision.tsx
new file mode 100644
index 00000000..57b84f77
--- /dev/null
+++ b/components/face-dance-decision.tsx
@@ -0,0 +1,111 @@
+'use client';
+import { useState } from 'react';
+import { territory, splitLocation } from '@/game/board';
+import type { Action, GameView } from '@/game/engine';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+export function FaceDanceDecision({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (a: Action) => void;
+ busy: boolean;
+}) {
+ const d = g.decision;
+ const me = g.players.find((p) => p.id === g.me)!;
+ const maximum =
+ d?.kind === 'faceDance'
+ ? Object.entries(g.players.find((p) => p.id === d.winner)!.forces)
+ .filter(([key]) => splitLocation(key).territory === d.territory)
+ .reduce((sum, [, count]) => sum + count, 0)
+ : 0;
+ const [sources, setSources] = useState>({
+ reserves: Math.min(me.reserves, maximum),
+ });
+ const [sector, setSector] = useState(
+ d?.kind === 'faceDance' ? territory(d.territory).sectors[0] : 0,
+ );
+ if (d?.kind !== 'faceDance') return null;
+ const held = me.faceDancers?.some(
+ (c) => !c.revealed && c.leader === d.identity,
+ );
+ const total = Object.values(sources).reduce((sum, n) => sum + n, 0);
+ return (
+ <>
+ Face Dancer opportunity
+
+ {g.players.find((p) => p.id === d.winner)?.name} won in{' '}
+ {territory(d.territory).name}. Their battle rewards remain theirs.
+
+ {held ? (
+ <>
+
+ Return the winner’s {maximum} remaining forces to reserves. Replace
+ up to {maximum} with your forces. Their leader goes to the tanks
+ without a spice bounty.
+
+ {Object.entries({ reserves: me.reserves, ...me.forces }).map(
+ ([key, available]) => (
+
+ {key === 'reserves'
+ ? 'Reserves'
+ : `${territory(splitLocation(key).territory).name} · sector ${splitLocation(key).sector}`}{' '}
+ ({available} available)
+
+ setSources({ ...sources, [key]: Number(e.target.value) })
+ }
+ />
+
+ ),
+ )}
+
+ Replacement sector
+ setSector(Number(e.target.value))}
+ >
+ {territory(d.territory).sectors.map((s) => (
+
+ {s === 0 ? 'Polar Sink' : `Sector ${s}`}
+
+ ))}
+
+
+
+ {total} / {maximum} replacement forces selected
+
+ maximum ||
+ Object.values(sources).some((n) => n < 0 || !Number.isInteger(n))
+ }
+ onClick={() =>
+ act({ type: 'decision', reveal: true, sources, sector })
+ }
+ >
+ Reveal Face Dancer
+
+ >
+ ) : (
+ You have no unrevealed Face Dancer matching the winning leader.
+ )}
+ act({ type: 'decision', reveal: false })}
+ >
+ Decline Face Dancer
+
+ >
+ );
+}
diff --git a/components/game-table.tsx b/components/game-table.tsx
new file mode 100644
index 00000000..f322928e
--- /dev/null
+++ b/components/game-table.tsx
@@ -0,0 +1,4872 @@
+'use client';
+import {
+ matchesShipment,
+ liveShipmentPromises,
+} from '@/game/shipment-promises';
+import { ShipmentPromises } from './shipment-promises';
+import {
+ StrongholdCardGallery,
+ StrongholdCopyChoice,
+} from './stronghold-cards';
+import { EcazPlacement, AmbassadorSupply } from './ecaz-ambassadors';
+import { EcazEntry, AmbassadorInsights } from './ecaz-entry';
+import { RicheseAuctionDecision, RicheseAuctionLot } from './richese-auctions';
+import { RicheseSpecialKarama } from './richese-special-karama';
+import { RicheseGift } from './richese-gift';
+import { Distrans } from './distrans';
+import { JuiceOfSapho } from './juice-of-sapho';
+import { NullentropyBox, NullentropySearch } from './nullentropy-box';
+import { OrnithopterMovement } from './ornithopter-movement';
+import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison';
+import { PortableSnooper } from './portable-snooper';
+import {
+ RicheseNoFieldControls,
+ NoFieldBoardMarkers,
+} from './richese-no-field';
+import {
+ RicheseAlliedNoFieldControls,
+ RicheseAlliedNoFieldDecision,
+} from './richese-allied-no-field';
+import { presenceByLocation } from '@/game/force-presence';
+import { AutomaticActionNotice } from './automatic-action-notice';
+import { EcazSpice } from './ecaz-spice';
+import { VictoryProgress } from './victory-progress';
+import { tableActionOwner } from '@/game/table-turn';
+import { DukeVidal } from './duke-vidal';
+import { CardInspector, CardRules } from './card-inspector';
+import { ordinaryCardAvailability } from '@/game/card-availability';
+import {
+ cardPresentation,
+ richeseCardActionBlock,
+} from '@/game/card-presentation';
+import { richeseCardDefinition } from '@/game/richese-cards';
+import { SpiceCardInspector, spiceCardTitle } from './spice-card-inspector';
+import { BattleWheel } from './battle-wheel';
+import {
+ LeaderInspector,
+ type LeaderDisplayIdentity,
+ type LeaderInspectorProps,
+} from './leader-inspector';
+import { ChoamStorm } from './choam-storm';
+import { ChoamBaliset } from './choam-baliset';
+import { ChoamGamont } from './choam-gamont';
+import { ChoamWorthless } from './choam-worthless';
+import { ChoamBattleFunding } from './choam-battle-funding';
+import { AuditorDecision, AuditorInsight } from './choam-auditor';
+import { ChoamCashIn } from './choam-cash-in';
+import { ChoamMarket } from './choam-market';
+import { BattlePromises } from './battle-promises';
+import { Truthtrance, TruthHistory } from './truthtrance';
+import {
+ forceRevivalPrice,
+ eliteRevivalRemaining,
+ paidForceRevivalCost,
+} from '@/game/revival';
+import { IxTechnology } from './ix-technology';
+import { MobileStronghold } from './mobile-stronghold';
+import { IxSubstitution } from './ix-substitution';
+import { controlsLeader } from '@/game/leader-control';
+import { leaderStrengthLabel, harvesterAvailable } from '@/game/cards';
+import { RevivalCommerce } from './revival-commerce';
+import { FaceDanceDecision } from './face-dance-decision';
+import { CHEAP_HERO_TRAITOR, matchingTraitor } from '@/game/traitors';
+import {
+ battleCardLabel,
+ isWeaponCard,
+ isStoneBurner,
+ isDefenseCard,
+} from '@/game/battle-cards';
+import { stoneBurnerPlanBlock } from '@/game/stone-burner';
+import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
+import Link from 'next/link';
+import { canUseAsKarama } from '@/game/karama';
+import { LeaderPortrait } from './leader-portrait';
+import { ShipmentQuote } from './shipment-quote';
+import { reserveShipmentCost } from '@/game/shipment-price';
+import { guildTransportQuote } from '@/game/transport-quote';
+import { fremenReserveEntry, botGroundMoveAllowed } from '@/game/bot-mobility';
+import { location as boardLocation } from '@/game/board';
+import { fighterCount, isAdvisor } from '@/game/advisors';
+import { useState } from 'react';
+import {
+ ArrowRight,
+ Wind,
+ Coins,
+ Copy,
+ Check,
+ Swords,
+ ZoomIn,
+ ZoomOut,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { BattlePreparation } from './battle-preparation';
+import { PrivateBattlePlan } from './private-battle-plan';
+import { RevealedBattle } from './revealed-battle';
+import { EliteCount } from './elite-count';
+import { HelpTip } from './help-tip';
+import { PHASE_HELP, phaseRuleId } from '@/game/reference';
+import {
+ DIFFICULTIES,
+ BOT_DESCRIPTIONS,
+ type Difficulty,
+} from '@/game/bot-profiles';
+import { FACTIONS, PHASES, faction } from '@/game/catalog';
+import {
+ FREMEN_START_LOCATIONS,
+ TERRITORIES,
+ gameTerritories,
+ MOBILE_STRONGHOLD,
+ territory,
+ splitLocation,
+} from '@/game/board';
+import type { Action, GameView } from '@/game/engine';
+import { PLAYER_CIRCLE_SECTORS } from '@/game/player-positions';
+import { SeatAutopilot } from './seat-autopilot';
+import {
+ MoritaniEntry,
+ MoritaniPlacement,
+ MoritaniTerrorSupply,
+} from './moritani-terror';
+import { TERROR_DEFINITIONS } from '@/game/moritani-terror';
+import { MoritaniRetention } from './moritani-retention';
+export function GameTable({
+ game: g,
+ send,
+ busy: transportBusy,
+ onExit,
+}: {
+ game: GameView;
+ send: (a: Action) => Promise;
+ busy: boolean;
+ onExit: () => void;
+}) {
+ const me = g.players.find((p) => p.id === g.me)!;
+ const turnOwner = tableActionOwner(g);
+ const turnOwnerName = g.players.find((p) => p.id === turnOwner)?.name;
+ const setupStage = g.setupStage;
+ const setupFremen = g.players.find((p) => p.faction === 'fremen');
+ const setupSisterhood = g.players.find((p) => p.faction === 'beneGesserit');
+ const setupPendingNames = (g.setupPending ?? [])
+ .filter((id) => id !== me.id)
+ .map((id) => g.players.find((p) => p.id === id)?.name)
+ .filter(Boolean)
+ .join(', ');
+ const waitingForFremen = !!setupFremen && setupFremen.reserves !== 10;
+ const setupStatus =
+ setupStage === 'prediction'
+ ? setupSisterhood?.id === me.id
+ ? 'Seal your secret prediction before traitors or treachery cards are dealt.'
+ : `Waiting for ${setupSisterhood?.name ?? 'Bene Gesserit'} to seal the secret prediction.`
+ : setupStage === 'traitors'
+ ? me.traitorChoices?.length
+ ? 'Choose one of your dealt traitors. Every player completes this step before starting forces are placed.'
+ : setupPendingNames
+ ? `Your traitors are settled. Waiting for ${setupPendingNames} to choose.`
+ : 'Your traitors are settled. The remaining selections are finishing.'
+ : setupStage === 'forces'
+ ? waitingForFremen
+ ? setupFremen.id === me.id
+ ? g.advanced && setupSisterhood
+ ? 'Place your ten starting forces. Bene Gesserit places its advisor afterward.'
+ : 'Place your ten starting forces. Starting cards are dealt automatically after placement.'
+ : `Waiting for ${setupFremen.name} to place the Fremen starting forces.`
+ : g.advanced && setupSisterhood && !setupSisterhood.advisorSetup
+ ? setupSisterhood.id === me.id
+ ? 'Choose a printed board territory and sector for your starting advisor.'
+ : `Waiting for ${setupSisterhood.name} to place the starting advisor.`
+ : 'Starting placements are finishing. Cards are dealt and the Storm phase opens automatically.'
+ : 'Complete your starting choices. Play begins when everyone is ready.';
+ const automaticEvents = g.log.flatMap((entry) =>
+ entry.automatic
+ ? [
+ {
+ id: `${g.code}:${entry.seq}`,
+ seq: entry.seq,
+ faction: entry.automatic.faction,
+ name: entry.automatic.name,
+ },
+ ]
+ : [],
+ );
+ const busy =
+ transportBusy || !!me.autopilot || g.automaticContinuationPending;
+ const traitorBattle = g.battle;
+ const traitorBeneficiary =
+ traitorBattle &&
+ [traitorBattle.attacker, traitorBattle.defender].includes(me.id)
+ ? me.id
+ : me.ally;
+ const traitorPlan = traitorBattle?.revealed
+ ? traitorBattle.plans[
+ traitorBattle.attacker === traitorBeneficiary
+ ? traitorBattle.defender
+ : traitorBattle.attacker
+ ]
+ : undefined;
+ const canRevealTraitor = !!(
+ traitorBattle?.traitorVoters.includes(me.id) &&
+ traitorPlan &&
+ !traitorPlan.kwisatz &&
+ matchingTraitor(
+ me.traitors ?? [],
+ traitorPlan.leader,
+ traitorBattle.cards.find((card) => card.id === traitorPlan.leader),
+ )
+ );
+ const noTraitorReason = traitorPlan?.kwisatz
+ ? 'Kwisatz Haderach prevents this leader from turning traitor.'
+ : 'You do not hold the opposing leader as a traitor.';
+ const guildTransport =
+ me.faction === 'guild' ||
+ g.players.some((p) => p.faction === 'guild' && p.id === me.ally);
+ const [botFaction, setBotFaction] = useState('');
+ const [botDifficulty, setBotDifficulty] = useState('Medium');
+ const availableBotFactions = FACTIONS.filter(
+ (f) =>
+ (f.expansion === 'base' || g.expansions.includes(f.expansion)) &&
+ !g.players.some((p) => p.faction === f.id),
+ );
+ const nextBotFaction = availableBotFactions.some((f) => f.id === botFaction)
+ ? botFaction
+ : (availableBotFactions[0]?.id ?? '');
+ const [selected, setSelected] = useState(g.battle?.territory ?? 'arrakeen');
+ const [sector, setSector] = useState(10);
+ const [amount, setAmount] = useState(1);
+ const [bidDraft, setBidDraft] = useState({ auction: '', value: 1 });
+ const auctionKey = `${g.code}/${g.turn}/${g.auction?.remaining ?? 0}`;
+ const minimumBid = (g.auction?.bid ?? 0) + 1;
+ const bidAmount = Math.max(
+ minimumBid,
+ bidDraft.auction === auctionKey ? bidDraft.value : minimumBid,
+ );
+ const maximumBid = me.hand?.some((c) =>
+ canUseAsKarama(g.advanced, me.faction, c),
+ )
+ ? Number.MAX_SAFE_INTEGER
+ : (me.spice ?? 0) + g.aid.available;
+ const [eliteAmount, setEliteAmount] = useState(0);
+ const [freeCyborgFirst, setFreeCyborgFirst] = useState(true);
+ const [allyEliteAmount, setAllyEliteAmount] = useState(0);
+ const [eliteForces, setEliteForces] = useState>({});
+ const [eliteSetup, setEliteSetup] = useState>({});
+ const [moveAsFighters, setMoveAsFighters] = useState(false);
+ const [source, setSource] = useState('');
+ const [target, setTarget] = useState('');
+ const [leader, setLeader] = useState('');
+ const [weapon, setWeapon] = useState('');
+ const [defense, setDefense] = useState('');
+ const [dial, setDial] = useState(0);
+ const [stormDial, setStormDial] = useState(0);
+ const stormDialValue = Math.max(
+ g.turn === 1 ? 0 : 1,
+ Math.min(
+ g.turn === 1 ? 20 : 3,
+ Number.isFinite(stormDial) ? Math.trunc(stormDial) : 0,
+ ),
+ );
+ const ownBattleForces = g.battle?.ownForces;
+ const battleForces = ownBattleForces
+ ? ownBattleForces.normal + ownBattleForces.elite
+ : g.battle
+ ? fighterCount(me, g.battle.territory)
+ : 0;
+ const battleDialMaximum = ownBattleForces
+ ? ownBattleForces.normal * (ownBattleForces.normalFixedHalf ? 0.5 : 1) +
+ ownBattleForces.elite * ownBattleForces.eliteStrength
+ : g.advanced || me.faction === 'ixians'
+ ? 40
+ : battleForces;
+ const battleDialStep = g.advanced || me.faction === 'ixians' ? 0.5 : 1;
+ // A draft carried from a previous battle must show and submit the same bounded value.
+ const battleDial = Number.isFinite(dial)
+ ? Math.max(
+ 0,
+ Math.min(
+ Math.round(dial / battleDialStep) * battleDialStep,
+ battleDialMaximum,
+ ),
+ )
+ : 0;
+ const [support, setSupport] = useState(0);
+ const strongholdBankSupport =
+ g.battle?.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0;
+ const battleSupportMaximum = Math.min(
+ (me.spice ?? 0) + g.aid.available + strongholdBankSupport,
+ ownBattleForces
+ ? ownBattleForces.freeSupport
+ ? 0
+ : (ownBattleForces.normalFixedHalf ? 0 : ownBattleForces.normal) +
+ ownBattleForces.elite
+ : Number.POSITIVE_INFINITY,
+ );
+ const battleSupport = Number.isFinite(support)
+ ? Math.max(0, Math.min(Math.trunc(support), battleSupportMaximum))
+ : 0;
+ const [kwisatz, setKwisatz] = useState(false);
+ const [weatherDistance, setWeatherDistance] = useState(0);
+ const [turn, setTurn] = useState(1);
+ const [copied, setCopied] = useState(false);
+ const [zoom, setZoom] = useState(1);
+ const [panel, setPanel] = useState<'hand' | 'leaders' | 'log' | 'rules'>(
+ 'hand',
+ );
+ const [fremen, setFremen] = useState>({
+ [FREMEN_START_LOCATIONS[0]]: 10,
+ });
+ const [aidAmount, setAidAmount] = useState(0);
+ const [allyPayment, setAllyPayment] = useState('');
+ const [combineSectors, setCombineSectors] = useState(false);
+ const [moveNoField, setMoveNoField] = useState(false);
+ const [moveForces, setMoveForces] = useState>({});
+ const [rideForces, setRideForces] = useState>({});
+ const [exchangeCards, setExchangeCards] = useState>(
+ {},
+ );
+ const [discardCards, setDiscardCards] = useState>({});
+ const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null;
+ const committed =
+ g.battle?.insight && g.battle.prescience?.player !== me.id
+ ? g.battle.insight
+ : null;
+ const selectedStone = me.hand?.find(
+ (c) =>
+ c.id === (committed?.field === 'weapon' ? committed.value : weapon) &&
+ isStoneBurner(c),
+ );
+ const stoneContext = g.battle?.stoneBurnerContext;
+ const stonePlanReason = !selectedStone
+ ? null
+ : (stoneContext?.blocked ??
+ (!ownBattleForces || !stoneContext?.opponentPools.length
+ ? 'Stone Burner needs a supported physical force pool.'
+ : (stoneContext.opponentPools
+ .map((pool) =>
+ stoneBurnerPlanBlock(
+ ownBattleForces,
+ committed?.field === 'dial'
+ ? Number(committed.value)
+ : battleDial,
+ battleSupport,
+ pool,
+ g.battle!.attacker === me.id ? 'attacker' : 'defender',
+ g.battle!.tieWinner === g.battle!.attacker
+ ? 'attacker'
+ : 'defender',
+ ),
+ )
+ .find((reason) => reason !== null) ?? null)));
+ const payment =
+ !me.ally || allyPayment === '' ? {} : { allyPayment: Number(allyPayment) };
+ const shipmentProblems: string[] = [];
+ if (
+ liveShipmentPromises(g.shipmentPromises, me.id, g.turn).some(
+ (p) => matchesShipment(p, { territory: selected, amount }) !== p.answer,
+ )
+ )
+ shipmentProblems.push(
+ 'This shipment conflicts with your Truthtrance answer. Choose a count and destination that honor your shipment promises.',
+ );
+ const selectedDestinationInStorm = sector !== 0 && sector === g.storm;
+ if (selectedDestinationInStorm && !(g.advanced && me.faction === 'fremen'))
+ shipmentProblems.push(
+ `Sector ${sector} of ${territory(selected).name} is in the storm. Choose a different sector or territory.`,
+ );
+ if (me.faction === 'fremen' && !fremenReserveEntry(selected))
+ shipmentProblems.push(
+ `${territory(selected).name} is beyond the Fremen reinforcement area. Choose the Great Flat or a territory within two territories of it.`,
+ );
+ const validShipmentAmount =
+ Number.isInteger(amount) && amount > 0 && amount <= me.reserves;
+ if (!validShipmentAmount)
+ shipmentProblems.push(
+ `Choose a whole number from 1 to ${me.reserves} available reserves.`,
+ );
+ if (me.elites && validShipmentAmount) {
+ const minimum = Math.max(0, amount - (me.reserves - me.elites.reserves));
+ const maximum = Math.min(amount, me.elites.reserves);
+ if (
+ !Number.isInteger(eliteAmount) ||
+ eliteAmount < minimum ||
+ eliteAmount > maximum
+ )
+ shipmentProblems.push(
+ `Choose ${minimum} to ${maximum} elite forces from reserves.`,
+ );
+ }
+ const shipmentRate = {
+ faction: me.faction,
+ halfRate:
+ me.faction === 'guild' ||
+ g.players.some((p) => p.faction === 'guild' && p.id === me.ally) ||
+ g.karamaShipping?.player === me.id,
+ };
+ const shipmentCost = validShipmentAmount
+ ? reserveShipmentCost(shipmentRate, territory(selected).type, amount)
+ : null;
+ const selectedAllyPayment =
+ shipmentCost === null
+ ? 0
+ : !me.ally || allyPayment === ''
+ ? Math.max(0, shipmentCost - (me.spice ?? 0))
+ : Number(allyPayment);
+ const validShipmentShare =
+ shipmentCost !== null &&
+ Number.isInteger(selectedAllyPayment) &&
+ selectedAllyPayment >= 0 &&
+ selectedAllyPayment <= shipmentCost;
+ if (shipmentCost !== null) {
+ if (!validShipmentShare)
+ shipmentProblems.push(
+ `Ally payment must be a whole number from 0 to ${shipmentCost}.`,
+ );
+ else {
+ if (selectedAllyPayment > g.aid.available)
+ shipmentProblems.push(
+ `Only ${g.aid.available} pledged ally spice is available.`,
+ );
+ if (shipmentCost - selectedAllyPayment > (me.spice ?? 0))
+ shipmentProblems.push(
+ 'Your spice does not cover your selected share. Adjust the ally payment or ship fewer forces.',
+ );
+ }
+ }
+ const shipmentQuote = validShipmentShare
+ ? {
+ cost: shipmentCost!,
+ normalCost: reserveShipmentCost(
+ { ...shipmentRate, halfRate: false },
+ territory(selected).type,
+ amount,
+ ),
+ ownPayment: shipmentCost! - selectedAllyPayment,
+ pledgedPayment: selectedAllyPayment,
+ }
+ : null;
+ const physicalMovementGroup = combineSectors
+ ? {
+ eliteForces,
+ forces: Object.fromEntries(
+ Object.entries(moveForces).filter(
+ ([key]) =>
+ splitLocation(key).territory === splitLocation(source).territory,
+ ),
+ ),
+ }
+ : { from: source, amount, elite: eliteAmount };
+ const ownNoField =
+ g.richeseNoField?.owner === me.id
+ ? g.richeseNoField.private?.deployed
+ : null;
+ const markerAtSource =
+ !!ownNoField &&
+ !!source &&
+ ownNoField.location.territory === splitLocation(source).territory;
+ const includesNoField = moveNoField && markerAtSource;
+ const physicalDraftEntries = combineSectors
+ ? Object.entries(moveForces).filter(
+ ([key]) =>
+ splitLocation(key).territory === splitLocation(source).territory,
+ )
+ : source
+ ? [[source, amount] as const]
+ : [];
+ const validMarkerGroup = physicalDraftEntries.every(
+ ([key, count]) =>
+ Number.isSafeInteger(count) &&
+ count >= 0 &&
+ count <= (me.forces[key] ?? 0),
+ );
+ const selectedPhysicalForces = Object.fromEntries(
+ physicalDraftEntries.filter(([, count]) => count > 0),
+ );
+ const markerPhysicalTotal = Object.values(selectedPhysicalForces).reduce(
+ (total, count) => total + count,
+ 0,
+ );
+ const movementGroup =
+ includesNoField && ownNoField
+ ? {
+ forces: selectedPhysicalForces,
+ ...(me.elites ? { eliteForces } : {}),
+ noField: ownNoField.tokenId,
+ event: g.richeseNoField!.event,
+ }
+ : physicalMovementGroup;
+ const transportAction: Action = {
+ type: 'guildShip',
+ ...payment,
+ ...movementGroup,
+ territory: selected,
+ sector,
+ amount,
+ };
+ const southernTransportAction: Action = {
+ type: 'guildShip',
+ from: 'reserves',
+ elite: eliteAmount,
+ territory: selected,
+ sector,
+ amount,
+ ...payment,
+ };
+ const returnTransportAction: Action = {
+ type: 'guildShip',
+ ...payment,
+ ...movementGroup,
+ territory: 'reserves',
+ amount,
+ };
+ const transportPreview =
+ guildTransport && !me.shipped
+ ? guildTransportQuote(g, transportAction)
+ : null;
+ const southernTransportPreview =
+ guildTransport && !me.shipped && me.faction === 'fremen'
+ ? guildTransportQuote(g, southernTransportAction)
+ : null;
+ const returnTransportPreview =
+ guildTransport && !me.shipped && me.faction === 'guild'
+ ? guildTransportQuote(g, returnTransportAction)
+ : null;
+ const canceledFremenRouteBlocked =
+ me.fremenMovementBlocked &&
+ physicalDraftEntries.some(
+ ([key, count]) =>
+ count > 0 &&
+ !botGroundMoveAllowed(g, me, key, boardLocation(selected, sector), 0),
+ );
+ const act = (a: Action) => void send(a);
+ const choose = (id: string) => {
+ setSelected(id);
+ setSector(territory(id).sectors[0]);
+ };
+ const leaderName = (id: string) =>
+ id === CHEAP_HERO_TRAITOR
+ ? 'Cheap Hero / Heroine'
+ : (g.allLeaders.find((l) => l.id === id)?.name ?? id);
+ const identityFor = (id: string): LeaderDisplayIdentity | undefined => {
+ if (id === CHEAP_HERO_TRAITOR)
+ return {
+ name: 'Cheap Hero / Heroine',
+ factionName: 'Any faction',
+ strength: 0,
+ cheapHero: true,
+ };
+ const known = g.allLeaders.find((l) => l.id === id);
+ return known
+ ? {
+ id: known.id,
+ name: known.name,
+ factionName: faction(known.faction).name,
+ strength: leaderStrengthLabel(known),
+ }
+ : undefined;
+ };
+ const inspectIdentity = (id: string, kind: LeaderInspectorProps['kind']) => {
+ const identity = identityFor(id);
+ return identity ? (
+
+ ) : null;
+ };
+ const actionButton = (
+ text: string,
+ a: Action,
+ disabled = false,
+ describedBy?: string,
+ ) => {
+ const promises = liveShipmentPromises(g.shipmentPromises, me.id, g.turn);
+ const shipmentBlocked =
+ g.phase === 5 &&
+ !me.shipped &&
+ ['ship', 'guildShip', 'move', 'endMovement'].includes(a.type) &&
+ promises.some(
+ (p) =>
+ matchesShipment(
+ p,
+ a.type === 'ship' ||
+ (a.type === 'guildShip' && a.from === 'reserves')
+ ? { territory: String(a.territory), amount: Number(a.amount) }
+ : null,
+ ) !== p.answer,
+ );
+ return (
+ act(a)}
+ >
+ {text}
+
+
+ );
+ };
+ const destination = (
+ <>
+
+ Destination
+ choose(e.target.value)}>
+ {gameTerritories(g).map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+
+ Sector
+ setSector(Number(e.target.value))}
+ >
+ {territory(selected).sectors.map((s) => (
+
+ {s === 0
+ ? selected === MOBILE_STRONGHOLD
+ ? 'Inside stronghold'
+ : 'Polar sink'
+ : s}
+ {s === g.storm ? ' · Storm' : ''}
+
+ ))}
+
+
+ >
+ );
+ const amountInput = (
+ <>
+
+ {includesNoField ? 'Physical forces accompanying No-Field' : 'Forces'}
+ setAmount(Number(e.target.value))}
+ />
+
+ {me.elites && (
+
+ )}
+ >
+ );
+ const sourceInput = (
+
+ From
+ {
+ const next = e.target.value;
+ setSource(next);
+ const markerOnly =
+ !!ownNoField &&
+ `${ownNoField.location.territory}:${ownNoField.location.sector}` ===
+ next &&
+ !(me.forces[next] > 0);
+ setMoveNoField(markerOnly);
+ if (markerOnly) setAmount(0);
+ else if (amount === 0) setAmount(1);
+ }}
+ >
+ Select your forces or No-Field
+ {Object.entries(presenceByLocation(me))
+ .filter(([, n]) => n > 0)
+ .map(([k, n]) => (
+
+ {territory(splitLocation(k).territory).name} ·{' '}
+ {splitLocation(k).sector} ({me.forces[k] ?? 0} physical forces
+ {n > (me.forces[k] ?? 0) ? ' + concealed No-Field' : ''})
+
+ ))}
+
+
+ );
+ return (
+
+
+
+
+ {g.status === 'lobby'
+ ? 'WAR COUNCIL'
+ : g.status === 'setup'
+ ? 'PREPARING THE TABLE'
+ : g.status === 'finished'
+ ? 'THE FATE OF ARRAKIS'
+ : PHASES[g.phase]?.toUpperCase()}
+
+
+ {g.status === 'lobby'
+ ? `${g.players.length} / 6 seats filled`
+ : g.status === 'finished'
+ ? g.winner
+ .map(
+ (id) =>
+ faction(g.players.find((p) => p.id === id)!.faction).name,
+ )
+ .join(' + ') || 'No winner'
+ : g.automaticContinuationPending
+ ? 'Completing the card action'
+ : g.truthtrance
+ ? 'Truthtrance in progress'
+ : g.phaseOpening
+ ? 'Phase-opening decisions'
+ : g.response
+ ? 'Karama response in progress'
+ : g.status === 'setup'
+ ? setupStatus
+ : turnOwnerName
+ ? `${turnOwnerName} to act`
+ : 'Simultaneous decisions'}
+
+
+ {g.status !== 'lobby' && (
+
+ {PHASES.map((p, i) => (
+
+ {i + 1}
+ {p}
+
+ ))}
+
+ )}
+ {g.sandtrout && (
+
+ Sandtrout is waiting: the next Shai-Hulud card will be suppressed. An
+ immediate territory replacement receives double spice.
+
+ )}
+ {g.spicePeek && (
+
+
+
Private foresight · Atreides
+
{spiceCardTitle(g.spicePeek)}
+
Only you can see this upcoming card.
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ THE GREAT HOUSES
+ {g.players.map((p) => (
+
+
+ {faction(p.faction).sigil}
+
+ {faction(p.faction).name}
+
+ {p.name}
+ {p.id === g.me ? ' · You' : ''}
+ {p.bot ? ` · ${p.bot}` : ''}
+ {p.autopilot ? ` · ${p.autopilot} autopilot` : ''}
+
+
+ {p.ready && g.status === 'lobby' && }
+
+
+ Player circle {g.playerPositions[p.id]} · Sector{' '}
+ {PLAYER_CIRCLE_SECTORS[g.playerPositions[p.id] - 1]}
+
+ {p.id === me.id && g.status === 'lobby' && (
+
+ Your player circle
+
+ act({
+ type: 'seatPosition',
+ position: Number(event.target.value),
+ })
+ }
+ >
+ {PLAYER_CIRCLE_SECTORS.map((circleSector, index) => {
+ const occupant = g.players.find(
+ (player) =>
+ player.id !== me.id &&
+ g.playerPositions[player.id] === index + 1,
+ );
+ return (
+
+ Circle {index + 1} · Sector {circleSector}
+ {occupant ? ` · ${occupant.name}` : ''}
+
+ );
+ })}
+
+
+ Circles determine storm order. Changing yours resets
+ readiness.
+
+
+ )}
+ {p.bot && g.status === 'lobby' && g.host === me.id && (
+
act({ type: 'removeBot', target: p.id })}
+ >
+ Remove AI
+
+ )}
+
+ {p.reserves} reserves
+ {p.tanks} tanks
+ {p.noField?.lastUsed !== undefined &&
+ p.noField.lastUsed !== null && (
+ No-Field {p.noField.lastUsed} · last used
+ )}
+ {p.elites && (
+
+ {Object.values(p.elites.forces).reduce((a, b) => a + b, 0)}{' '}
+ elite on Dune
+
+ )}
+
+ {p.revealedTraitors.length > 0 && (
+
+ Revealed traitors:{' '}
+ {p.revealedTraitors.map(leaderName).join(', ')}
+
+ )}
+ {!!p.revealedFaceDancers.length && (
+
+ Revealed Face Dancers:{' '}
+ {p.revealedFaceDancers.map(leaderName).join(', ')}
+
+ )}
+ {g.techTokens &&
+ ownedTech(g.techTokens, p.id).map((id) => (
+
+ {TECH_TOKENS.find((t) => t.id === id)!.name}
+ {g.techTokens![id].spice > 0 &&
+ ` · ${g.techTokens![id].spice} spice at phase end`}
+
+ ))}
+ {p.ally && (
+
+ Allied with{' '}
+ {
+ faction(g.players.find((x) => x.id === p.ally)!.faction)
+ .name
+ }
+
+ )}
+
+ ))}
+ {g.status === 'lobby' &&
+ g.host === me.id &&
+ g.players.length < 6 &&
+ availableBotFactions.length > 0 && (
+
+ Add an AI player
+
+ Faction
+ setBotFaction(e.target.value)}
+ >
+ {availableBotFactions.map((f) => (
+
+ {f.name}
+
+ ))}
+
+
+
+ Difficulty
+
+ setBotDifficulty(e.target.value as Difficulty)
+ }
+ >
+ {DIFFICULTIES.map((d) => (
+ {d}
+ ))}
+
+
+ {BOT_DESCRIPTIONS[botDifficulty]}
+ {actionButton('Add AI player', {
+ type: 'addBot',
+ faction: nextBotFaction,
+ difficulty: botDifficulty,
+ })}
+
+ )}
+ {me.leaders.some((l) => l.capturedBy) && (
+
+ {me.leaders
+ .filter((l) => l.capturedBy)
+ .map(
+ (l) =>
+ `${l.name}: ${l.capturedBy === me.id ? 'your captive' : 'captured by Harkonnen'}`,
+ )
+ .join(' · ')}
+
+ )}
+ {me.leaders.some((l) => l.gholaBy) && (
+
+ Gholas:{' '}
+ {me.leaders
+ .filter((l) => l.gholaBy)
+ .map(
+ (l) =>
+ `${l.name} · ${l.gholaBy === me.id ? 'your leader pool' : 'Tleilaxu leader pool'}${l.dead ? ' · in the tanks' : ''}`,
+ )
+ .join(' / ')}
+
+ )}
+ {me.kwisatz && (
+
+ Kwisatz Haderach:{' '}
+ {me.kwisatz.dead
+ ? 'awaiting revival'
+ : me.kwisatz.active
+ ? 'awakened'
+ : `${me.kwisatz.losses} of 7 battle losses`}
+ {me.kwisatz.usedAt
+ ? ` · used in ${territory(me.kwisatz.usedAt).name} this turn`
+ : ''}
+
+ )}
+ {g.stormForecast !== null && (
+
+ Your storm forecast: {g.stormForecast} sectors next turn.
+
+ )}
+
+
+ {me.spice ?? 0}
+
+ Your spice
+
+
+
+ Private hands and spice are visible only to their owner.
+
+
+
+
+
+
+ Storm · Sector {g.storm}
+
+
+ setZoom(Math.max(1, zoom - 0.25))}
+ >
+
+
+ setZoom(Math.min(2.5, zoom + 0.25))}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {PLAYER_CIRCLE_SECTORS.map((circleSector, index) => {
+ const angle = ((110 - (circleSector - 1) * 20) * Math.PI) / 180;
+ const player = g.players.find(
+ (p) => g.playerPositions[p.id] === index + 1,
+ );
+ return (
+
+
+ Player circle {index + 1}, sector {circleSector}:{' '}
+ {player
+ ? `${player.name} · ${faction(player.faction).name}`
+ : 'Empty'}
+
+
+
+ {index + 1}
+
+
+ );
+ })}
+ {g.mobileStronghold?.location && (
+
+ )}
+ {gameTerritories(g).map((t) => (
+ choose(t.id)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ choose(t.id);
+ }
+ }}
+ >
+ p.join(',')).join(' ')}
+ />
+
+ {t.name}
+
+
+ ))}
+ {Array.from({ length: 18 }, (_, i) => {
+ const a = (Math.PI / 180) * (120 - i * 20);
+ return (
+
+
+
+ {i + 1}
+
+
+ );
+ })}
+
+
+
+ {gameTerritories(g).flatMap((t) => {
+ const present = g.players.filter((p) =>
+ Object.keys(p.forces).some(
+ (k) => splitLocation(k).territory === t.id && p.forces[k],
+ ),
+ );
+ return present.map((p, i) => (
+
+
+ {isAdvisor(p, t.id) && (
+
+ )}
+
+ {Object.entries(p.forces)
+ .filter(([k]) => splitLocation(k).territory === t.id)
+ .reduce((n, [, v]) => n + v, 0)}
+
+
+ ));
+ })}
+
+ {g.moritaniTerror?.tokens
+ .filter((token) => token.status === 'placed' && token.location)
+ .map((token) => {
+ const t = territory(token.location!);
+ return (
+
+ Hidden Terror token · {t.name}
+
+
+ T
+
+
+ );
+ })}
+ {Object.entries(g.spice)
+ .filter(([, n]) => n > 0)
+ .map(([k, n]) => {
+ const t = territory(splitLocation(k).territory);
+ return (
+
+
+
+ ◆ {n}
+
+
+ );
+ })}
+
+
+
+
+
+ {territory(selected).type.toUpperCase()}
+
+
{territory(selected).name}
+
+
+ {selected === MOBILE_STRONGHOLD && g.mobileStronghold?.location
+ ? `Points at ${territory(splitLocation(g.mobileStronghold.location).territory).name}, sector ${splitLocation(g.mobileStronghold.location).sector}`
+ : `Sectors ${territory(selected).sectors.join(' · ')}`}
+
+
+
+ {g.players.flatMap((p) =>
+ Object.entries(p.forces)
+ .filter(([k]) => splitLocation(k).territory === selected)
+ .map(([k, n]) => (
+
+ {faction(p.faction).name}: {n}{' '}
+ {isAdvisor(p, selected) ? 'advisors' : 'forces'} in sector{' '}
+ {splitLocation(k).sector}
+
+ )),
+ )}
+ {g.players
+ .filter(
+ (p) => p.noField?.deployed?.location.territory === selected,
+ )
+ .map((p) => (
+
+ {faction(p.faction).name}: concealed No-Field in sector{' '}
+ {p.noField!.deployed!.location.sector} · counts as one force
+
+ ))}
+
+ {g.status !== 'lobby' && (
+
+ {g.playedOrnithopter && (
+
+ Ornithopter in play
+
+ {
+ g.players.find(
+ (p) => p.id === g.playedOrnithopter!.player,
+ )?.name
+ }{' '}
+ ·{' '}
+ {g.playedOrnithopter.mode === 'range3'
+ ? 'Three-territory group'
+ : 'Two different groups'}{' '}
+ · {g.playedOrnithopter.completed} completed
+
+
+
+ )}
+ Spice cards on the table
+
+ {g.spiceDiscardTop
+ .slice(0, g.advanced ? 2 : 1)
+ .map((card, index) => (
+
+
+ {g.advanced ? `Spice pile ${index + 1}` : 'Spice pile'}{' '}
+ · top card
+
+ {card ? (
+ <>
+ {spiceCardTitle(card)}
+
+ >
+ ) : (
+ No face-up card yet.
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+ {g.status === 'finished' ? 'FINAL OUTCOME' : 'YOUR NEXT DECISION'}
+
+
+
+
+ {g.truthtrance ? (
+
+ ) : g.status === 'lobby' ? (
+ <>
+ Assemble the houses
+
+ Share the invite above. Every player chooses a faction and
+ confirms they are ready.
+
+
+ Your faction
+
+ act({ type: 'faction', faction: e.target.value })
+ }
+ >
+ {FACTIONS.filter(
+ (f) =>
+ f.expansion === 'base' ||
+ g.expansions.includes(f.expansion),
+ ).map((f) => (
+ p.id !== me.id && p.faction === f.id,
+ )}
+ value={f.id}
+ key={f.id}
+ >
+ {f.name}
+
+ ))}
+
+
+
+
+ act({ type: 'techTokens', enabled: e.target.checked })
+ }
+ />
+ Tech tokens · optional, 3+ players{' '}
+
+
+
+
+ act({ type: 'strongholdCards', enabled: e.target.checked })
+ }
+ />
+ Stronghold Cards · Advanced rules required
+
+ {actionButton(me.ready ? 'Not ready' : 'Ready to play', {
+ type: 'ready',
+ })}
+ {g.host === me.id &&
+ actionButton(
+ 'Begin game',
+ { type: 'start' },
+ g.players.length < (g.techTokens ? 3 : 2) ||
+ !g.players.every((p) => p.ready),
+ )}
+
+ Playable core under development. Advanced rules and expansion
+ games are not yet available.
+
+ >
+ ) : g.status === 'setup' && !g.decision ? (
+ <>
+
+ {setupStage === 'prediction'
+ ? 'Seal the prediction'
+ : setupStage === 'traitors'
+ ? 'Choose your traitor'
+ : setupStage === 'forces'
+ ? 'Place starting forces'
+ : 'Secrets & allegiances'}
+
+
+ {setupStatus}
+
+ {setupStage && (
+
+ Setup order: prediction, traitors, starting forces, then the
+ automatic treachery-card deal and Storm phase.
+
+ )}
+ {(!setupStage || setupStage === 'forces') &&
+ g.advanced &&
+ me.faction === 'beneGesserit' &&
+ !me.advisorSetup && (
+ <>
+
+ After Fremen placement, choose a territory and sector on
+ the map for your starting advisor. It becomes a fighter if
+ alone.
+
+ {destination}
+ {waitingForFremen && (
+
+ {setupFremen.name} must finish placing the Fremen forces
+ before you place an advisor.
+
+ )}
+ {selected === MOBILE_STRONGHOLD && (
+
+ Choose a territory on the printed board for your
+ starting advisor.
+
+ )}
+ {actionButton(
+ 'Place starting advisor',
+ { type: 'advisorSetup', territory: selected, sector },
+ waitingForFremen ||
+ me.reserves !== 20 ||
+ selected === MOBILE_STRONGHOLD,
+ waitingForFremen ? 'advisor-setup-wait' : undefined,
+ )}
+ >
+ )}
+ {(!setupStage || setupStage === 'traitors') &&
+ !!me.traitorChoices?.length && (
+ <>
+
+ Keep one traitor. Your selection remains secret.
+
+
+ {me.traitorChoices.map((id) => (
+
+ {leaderName(id)}
+ {inspectIdentity(id, 'traitor')}
+ {actionButton(`Keep ${leaderName(id)}`, {
+ type: 'traitor',
+ leader: id,
+ })}
+
+ ))}
+
+ >
+ )}
+ {(!setupStage || setupStage === 'forces') &&
+ me.faction === 'fremen' &&
+ me.reserves === 20 && (
+ <>
+ Distribute ten forces.
+ {FREMEN_START_LOCATIONS.map((key) => {
+ const loc = splitLocation(key);
+ return (
+
+ {territory(loc.territory).name} · Sector {loc.sector}
+
+ setFremen({
+ ...fremen,
+ [key]: Number(e.target.value),
+ })
+ }
+ />
+
+ );
+ })}
+ {me.elites &&
+ FREMEN_START_LOCATIONS.map((key) => (
+
+ setEliteSetup({ ...eliteSetup, [key]: n })
+ }
+ />
+ ))}
+
+ {Object.values(fremen).reduce((a, b) => a + b, 0)} / 10
+ forces assigned
+
+ {actionButton(
+ 'Place forces',
+ {
+ type: 'fremenSetup',
+ placements: fremen,
+ elitePlacements: eliteSetup,
+ },
+ Object.values(fremen).reduce((a, b) => a + b, 0) !== 10,
+ )}
+ >
+ )}
+ {(!setupStage || setupStage === 'prediction') &&
+ me.faction === 'beneGesserit' &&
+ !me.prediction && (
+ <>
+
+ Predicted victor
+ setTarget(e.target.value)}
+ >
+ Choose a faction
+ {g.players
+ .filter((p) => p.id !== me.id)
+ .map((p) => (
+
+ {faction(p.faction).name}
+
+ ))}
+
+
+
+ Predicted turn
+ setTurn(Number(e.target.value))}
+ />
+
+ {actionButton(
+ 'Seal prediction',
+ { type: 'predict', faction: target, turn },
+ !g.players.some(
+ (p) => p.id !== me.id && p.faction === target,
+ ) ||
+ !Number.isInteger(turn) ||
+ turn < 1 ||
+ turn > 10,
+ )}
+ >
+ )}
+ {!setupStage && (
+
+ Play begins when everyone has completed their starting
+ choices.
+
+ )}
+ >
+ ) : g.status === 'finished' ? (
+ <>
+
+ {g.winner.includes(me.id)
+ ? 'Arrakis is yours.'
+ : 'The struggle is over.'}
+
+
+ {g.winner
+ .map(
+ (id) =>
+ faction(g.players.find((p) => p.id === id)!.faction).name,
+ )
+ .join(' and ')}{' '}
+ won on turn {g.turn}.
+
+
+ The battle record remains available below.
+
+ >
+ ) : g.phaseOpening ? (
+ <>
+ Phase opening
+ Before {PHASES[g.phase]}
+
+ Play Amal now, or pass to begin this phase. Phase effects wait
+ until everyone has passed.
+
+ {me.hand
+ ?.filter((c) => c.effect === 'amal')
+ .map((c) => (
+
+
+ Amal returns half of every faction’s available spice to
+ the bank, rounded up. Your loss:{' '}
+ {Math.ceil((me.spice ?? 0) / 2)} spice.
+
+ {actionButton(
+ 'Play Amal',
+ { type: 'card', card: c.id },
+ g.phaseOpening!.passed.includes(me.id),
+ )}
+
+ ))}
+ {actionButton(
+ 'Pass phase opening',
+ { type: 'ready' },
+ g.phaseOpening.passed.includes(me.id),
+ )}
+
+ {g.phaseOpening.passed.length} / {g.players.length} passed.
+ Playing Amal asks everyone to confirm again.
+
+ >
+ ) : g.response ? (
+ <>
+ Power response
+
+ {
+ {
+ moritaniAlliance: 'Moritani · Enemy of My Enemy',
+ moritaniRetention: 'Moritani · allied card retention',
+ moritaniDuke: 'Moritani · Duke Vidal acquisition',
+ ecazPlacement: 'Ecaz Ambassador placement',
+ ecazCollection: 'Ecaz stronghold collection',
+ moritaniPlacement:
+ 'Moritani Terror placement or relocation',
+ choamRevival: 'CHOAM force revival',
+ revivalLimit: 'Expanded force revival',
+ revivalDiscount: 'Half-price revival',
+ earlyRevival: 'Early leader revival',
+ revivalIncome: 'Tleilaxu revival payment',
+ foreignGhola: 'Tleilaxu foreign ghola revival',
+ ixSubstitution: 'Suboid casualty substitution',
+ ixMovement: 'Cyborg movement',
+ fremenMovement: 'Fremen movement',
+ ixAuction: 'Ixian auction inspection',
+ richeseAuction: 'Richese cache auction',
+ richeseBlackMarket: 'Richese Black Market offer',
+ richesePurchaseIncome: 'Emperor card-purchase income',
+ richeseGift: 'Richese allied card gift',
+ ixTechnology: 'Ixian auction substitution',
+ ixAllyCard: 'Ixian allied purchase replacement',
+ mobileStronghold: 'Mobile stronghold relocation',
+ faceDancerReplacement: 'Replace an unrevealed Face Dancer',
+ voice: 'The Voice',
+ prescience: 'Prescience',
+ emperorIncome:
+ g.response.source === 'ambassador'
+ ? 'Emperor card-purchase income'
+ : 'Emperor auction income',
+ harkonnenBonus: 'Harkonnen bonus card',
+ harkonnenTraitor: 'Harkonnen ally traitor',
+ guildIncome: 'Guild shipment income',
+ richeseNoField: 'Richese No-Field shipment',
+ advisor: 'Spiritual advisor',
+ emperorGift: 'Emperor spice gift',
+ emperorRevival: 'Emperor extra revival',
+ bgCharity: 'Bene Gesserit charity',
+ choamCharity: 'CHOAM charity income',
+ choamInflation: 'CHOAM Inflation placement',
+ choamSale: 'CHOAM card sale',
+ choamWorthless: 'CHOAM Worthless card effect',
+ choamBattleIncome: 'CHOAM battle force income',
+ choamAudit: 'CHOAM Auditor inspection',
+ choamBattleAid: 'CHOAM allied combat funding',
+ worthlessKarama: 'Worthless card as Karama',
+ advisorFlip: 'Bene Gesserit token flip',
+ eliteStrength: 'Elite battle strength',
+ fremenSupport: 'Fremen full strength without spice',
+ capture: 'Harkonnen leader capture',
+ kwisatz: 'Kwisatz Haderach battle protection',
+ guildTiming: 'Guild shipment and movement timing',
+ atreidesAuction: 'Atreides auction foresight',
+ atreidesSpice: 'Atreides spice foresight',
+ stormPeek: 'Fremen storm foresight',
+ stormProtection: 'Fremen storm survival',
+ wormPlacement: 'Fremen additional worm placement',
+ wormSurvival: 'Fremen worm survival',
+ wormAllyProtection: 'Fremen allied worm protection',
+ }[g.response.kind]
+ }
+
+ {g.response.kind === 'moritaniRetention' &&
+ g.moritaniRetentionCard && (
+
+
+ {
+ g.players.find(
+ (player) => player.id === g.moritaniRetention?.player,
+ )?.name
+ }{' '}
+ has declared {g.moritaniRetentionCard.name} to retain
+ after losing the battle. Canceling this power discards all
+ of that player’s played cards.
+
+
+
+ )}
+ {g.response.kind === 'moritaniPlacement' &&
+ g.moritaniPendingPlacement && (
+
+ Your private declaration:{' '}
+ {(() => {
+ const token = g.moritaniTerror?.tokens.find(
+ (t) => t.id === g.moritaniPendingPlacement!.token,
+ );
+ return token && 'kind' in token
+ ? TERROR_DEFINITIONS[token.kind].name
+ : 'Terror token';
+ })()}{' '}
+ into {territory(g.moritaniPendingPlacement.territory).name}.
+ The token has not moved yet.
+
+ )}
+ {g.response.kind === 'choamInflation' && (
+
+ CHOAM will{' '}
+ {g.response.intent === 'double' ? 'double' : 'cancel'} next
+ turn’s charity. The following turn uses the opposite effect.
+ Karama can stop this placement; the later flip and removal are
+ mandatory.
+
+ )}
+ {g.response.kind === 'choamWorthless' && (
+
+ CHOAM declared {g.response.intent}.{' '}
+ {g.response.location &&
+ (g.response.intent === 'Jubba Cloak'
+ ? `Protect CHOAM’s forces in ${territory(splitLocation(g.response.location).territory).name} during this storm movement. `
+ : g.response.intent === 'Baliset'
+ ? `Prevent ${g.players.find((p) => p.id === g.response?.recipient)?.name} from moving into ${territory(splitLocation(g.response.location).territory).name}. `
+ : `Selected: ${g.players.find((p) => p.id === g.response?.recipient)?.name}, ${territory(splitLocation(g.response.location).territory).name}, sector ${splitLocation(g.response.location).sector}, ${g.response.elite ? 'elite' : 'ordinary'} force. `)}
+ Karama prevents this card’s effect for this phase and leaves
+ the card in hand.
+
+ )}
+ {g.response.kind === 'choamBattleIncome' && (
+
+ CHOAM is due {g.response.amount} spice from other players’
+ force payments. Karama sends this battle’s income to the bank.
+
+ )}
+ {g.response.kind === 'choamBattleAid' && (
+
+ Karama can prevent CHOAM funding its ally’s forces for this
+ battle. The ally will then plan using its own spice.
+
+ )}
+ {g.response.kind === 'choamRevival' && (
+
+ CHOAM revives paid forces for one spice each with no normal
+ quantity limit. Karama restores the three-force limit and
+ two-spice price for this phase. Tleilaxu permission can still
+ allow five forces.
+
+ )}
+ {g.response.kind === 'choamCharity' && (
+
+ CHOAM receives {2 * g.players.length * g.charity.multiplier}{' '}
+ spice before anyone claims charity. Karama cancels this income
+ and sends this turn’s charity payments to the Spice Bank.
+
+ )}
+ {g.response.kind === 'mobileStronghold' && g.mobileRoute && (
+ <>
+
+ The Ixians declared this route before the storm. Karama
+ cancels both the relocation and its spice collection.
+
+
+ {g.mobileRoute.route.map((key) => {
+ const loc = splitLocation(key);
+ return (
+
+ {territory(loc.territory).name} · sector {loc.sector}
+
+ );
+ })}
+
+
+ Spice collection{' '}
+ {g.mobileRoute.collect ? 'requested' : 'declined'}.
+
+ >
+ )}
+ {g.response.kind === 'fremenMovement' && g.response.location && (
+
+ {g.response.amount} Fremen forces will move to{' '}
+ {territory(splitLocation(g.response.location).territory).name}
+ , sector {splitLocation(g.response.location).sector}, using
+ their two-territory movement advantage. If canceled, the
+ forces remain in place and Fremen may choose a one-territory
+ move.
+
+ )}
+ {g.response.kind === 'ixMovement' && g.response.location && (
+
+ {g.response.amount} forces, including {g.response.elite}{' '}
+ cyborgs, moving to{' '}
+ {territory(splitLocation(g.response.location).territory).name}
+ , sector {splitLocation(g.response.location).sector}.
+
+ )}
+
+ {g.players.find((p) => p.id === g.response?.owner)?.name} is
+ using this power. Another faction may play Karama to cancel this
+ use.
+
+ {g.response.kind === 'revivalIncome' &&
+ g.response.amount !== undefined && (
+
+ {g.response.amount} spice is payable to Tleilaxu after this
+ response.
+
+ )}
+ {g.response.source === 'ambassador' && (
+
+ The Richese Ambassador purchase is already paid and its card
+ has been drawn. Canceling this benefit leaves that purchase
+ unchanged.
+
+ )}
+ {g.response.intent &&
+ g.response.source !== 'ambassador' &&
+ g.response.kind !== 'faceDancerReplacement' && (
+ {g.response.intent}
+ )}
+ {me.hand
+ ?.filter((c) => g.responseControls?.cancelCards.includes(c.id))
+ .map((c) => (
+
+ act({ type: 'card', card: c.id, mode: 'cancel' })
+ }
+ >
+ {c.kind === 'worthless'
+ ? `Use ${c.name} as Karama`
+ : 'Cancel with Karama'}
+
+ ))}
+ {g.responseControls?.cancelCards.length &&
+ !g.responseControls.hasPassed ? (
+ actionButton('Allow this power', { type: 'passResponse' })
+ ) : (
+
+ {g.responseControls?.hasPassed
+ ? 'You have allowed this power. Waiting for the response to finish.'
+ : 'Waiting for the response to finish.'}
+
+ )}
+ >
+ ) : g.decision ? (
+ <>
+ Player decision
+
+ {g.decision.kind === 'stoneBurner'
+ ? 'Stone Burner'
+ : g.decision.kind === 'nullentropy'
+ ? 'Private Nullentropy Box search'
+ : g.decision.kind === 'richeseAllyOpportunity'
+ ? 'Offer allied No-Field shipment'
+ : g.decision.kind === 'richeseAllyShipment'
+ ? 'Allied No-Field shipment'
+ : [
+ 'richeseBlackMarket',
+ 'richeseDeclaration',
+ 'richeseCache',
+ 'richeseUnbid',
+ ].includes(g.decision.kind)
+ ? 'Richese auction decision'
+ : g.decision.kind === 'ecazAmbassador'
+ ? 'Ecaz · Ambassador entry effect'
+ : g.decision.kind === 'ecazPlacement'
+ ? 'Ecaz · Ambassador placement'
+ : g.decision.kind === 'moritaniRetention'
+ ? 'Moritani · allied card retention'
+ : g.decision.kind === 'moritaniTerror'
+ ? 'Moritani · Terror entry reaction'
+ : g.decision.kind === 'moritaniSetup'
+ ? 'Moritani · final deployment'
+ : g.decision.kind === 'moritaniPlacement'
+ ? 'Moritani · hidden Terror placement'
+ : g.decision.kind === 'choamStorm'
+ ? 'CHOAM storm protection'
+ : g.decision.kind === 'choamMovement'
+ ? 'CHOAM movement response'
+ : g.decision.kind === 'choamMentat'
+ ? 'CHOAM’s final Mentat opportunity'
+ : g.decision.kind ===
+ 'choamFreeRevival'
+ ? 'CHOAM free revival response'
+ : g.decision.kind ===
+ 'choamBattleFunding'
+ ? 'CHOAM battle funding'
+ : g.decision.kind ===
+ 'choamMarket'
+ ? 'CHOAM · end of phase'
+ : g.decision.kind ===
+ 'choamTradeReply' ||
+ g.decision.kind ===
+ 'choamTradeConfirm'
+ ? 'Allied card exchange'
+ : g.decision.kind ===
+ 'ixSetup'
+ ? 'Choose your starting technology'
+ : g.decision.kind ===
+ 'ixAuction'
+ ? 'Prepare the auction'
+ : g.decision.kind ===
+ 'ixTechnology'
+ ? 'Substitute an auction card'
+ : g.decision.kind ===
+ 'ixAllyCard'
+ ? 'Keep or replace your purchase'
+ : g.decision
+ .kind ===
+ 'mobileStronghold'
+ ? g.decision
+ .placement
+ ? 'Place your stronghold'
+ : 'Relocate before the storm'
+ : g.decision
+ .kind ===
+ 'ixSubstitution'
+ ? 'Retain lost cyborgs'
+ : g.decision
+ .kind ===
+ 'revivalStop'
+ ? 'A revival awaits clearance'
+ : g.decision
+ .kind ===
+ 'faceDance'
+ ? 'A hidden allegiance'
+ : g.decision
+ .kind ===
+ 'poisonTooth'
+ ? 'Activate Poison Tooth?'
+ : g
+ .decision
+ .kind ===
+ 'techToken'
+ ? 'Claim a tech token'
+ : g
+ .decision
+ .kind ===
+ 'fullPlanOffer'
+ ? 'See the entire battle plan'
+ : g
+ .decision
+ .kind ===
+ 'guildShipment'
+ ? 'A shipment awaits clearance'
+ : g
+ .decision
+ .kind ===
+ 'handExchange'
+ ? 'Choose cards to return'
+ : g
+ .decision
+ .kind ===
+ 'captureOffer' ||
+ g
+ .decision
+ .kind ===
+ 'capturedLeader'
+ ? 'A captive leader'
+ : g
+ .decision
+ .kind ===
+ 'guildTiming'
+ ? 'Choose when to act'
+ : g
+ .decision
+ .kind ===
+ 'stormLosses'
+ ? 'Survive the storm'
+ : g
+ .decision
+ .kind ===
+ 'wormPlacement'
+ ? 'Place an additional worm'
+ : g
+ .decision
+ .kind ===
+ 'battleLosses'
+ ? 'Choose your casualties'
+ : g
+ .decision
+ .kind ===
+ 'auctionPayment'
+ ? 'Settle your bid'
+ : [
+ 'advisor',
+ 'intrusion',
+ 'advisorBattle',
+ ].includes(
+ g
+ .decision
+ .kind,
+ )
+ ? 'Advisors & fighters'
+ : g
+ .decision
+ .kind ===
+ 'wormProtection'
+ ? 'Protection of Shai-Hulud'
+ : g
+ .decision
+ .kind ===
+ 'wormRide'
+ ? 'Ride the sandworm'
+ : 'Spoils of battle'}
+
+ {g.decision.kind === 'capturedLeader' &&
+ (g.decision.controller ?? g.decision.owner) === me.id && (
+
+ Your leader {leaderName(g.decision.leader)} was captured.
+
+ )}
+ {g.decision.kind === 'choamMovement' &&
+ g.decision.player !== me.id && (
+
+ {
+ g.players.find(
+ (p) =>
+ p.id ===
+ (g.decision?.kind === 'choamMovement'
+ ? g.decision.mover
+ : ''),
+ )?.name
+ }{' '}
+ has declared a move of {g.decision.amount} forces into{' '}
+ {territory(g.decision.territory).name}, sector{' '}
+ {g.decision.sector}. CHOAM must allow the move or respond
+ with Baliset; no forces have moved yet.
+
+ )}
+ {g.decision.kind === 'choamStorm' &&
+ g.decision.player !== me.id && (
+
+ The storm is waiting for CHOAM to choose protection.
+ Threatened forces:{' '}
+ {g.decision.territories
+ .map(
+ (t) => `${territory(t.territory).name} (${t.amount})`,
+ )
+ .join(', ')}
+ . No storm casualties have been applied.
+
+ )}
+ {g.decision.player !== me.id ? (
+
+ Waiting for{' '}
+ {g.players.find((p) => p.id === g.decision?.player)?.name} to
+ complete this decision.
+
+ ) : g.decision.kind === 'nullentropy' ? (
+
+ ) : g.decision.kind === 'richeseAllyOpportunity' ? (
+ <>
+
+ Your ally’s shipment is waiting for your optional No-Field
+ offer. Choose the private token, destination and payer in
+ the controls above, or let your ally ship normally.
+
+ {actionButton('Let ally ship normally', {
+ type: 'decision',
+ decline: true,
+ })}
+ >
+ ) : g.decision.kind === 'richeseAllyShipment' ? (
+
+ ) : g.decision.kind === 'richeseBlackMarket' ||
+ g.decision.kind === 'richeseDeclaration' ||
+ g.decision.kind === 'richeseCache' ||
+ g.decision.kind === 'richeseUnbid' ? (
+
+ ) : g.decision.kind === 'moritaniRetention' ? (
+
+ ) : g.decision.kind === 'moritaniTerror' ? (
+
+ ) : g.decision.kind === 'moritaniSetup' ? (
+ <>
+
+ Place six starting forces in one unoccupied territory.
+ Select a territory and sector on the map. Fourteen forces
+ remain in reserves.
+
+
+ Advisors also occupy a territory. The Hidden Mobile
+ Stronghold cannot be chosen.
+
+ {actionButton(
+ 'Place six starting forces',
+ { type: 'decision', territory: selected, sector },
+ !TERRITORIES.some(
+ (t) => t.id === selected && t.sectors.includes(sector),
+ ) ||
+ g.players.some((p) =>
+ Object.entries(p.forces).some(
+ ([key, n]) =>
+ n > 0 && splitLocation(key).territory === selected,
+ ),
+ ),
+ )}
+ >
+ ) : g.decision.kind === 'ecazAmbassador' ? (
+
+ ) : g.decision.kind === 'ecazSpice' ? (
+ g.ecazSpice?.allocation ? (
+
+ ) : (
+ Waiting for the current spice allocation.
+ )
+ ) : g.decision.kind === 'ecazPlacement' ? (
+
+ ) : g.decision.kind === 'moritaniPlacement' ? (
+
+ ) : g.decision.kind === 'choamStorm' ? (
+
+ ) : g.decision.kind === 'choamMovement' ? (
+
+ ) : g.decision.kind === 'choamMentat' ? (
+
+ ) : g.decision.kind === 'choamFreeRevival' ? (
+
+ ) : g.decision.kind === 'choamBattleFunding' ? (
+
+ ) : g.decision.kind === 'choamMarket' ||
+ g.decision.kind === 'choamTradeReply' ||
+ g.decision.kind === 'choamTradeConfirm' ? (
+
+ ) : g.decision.kind === 'ixSetup' ||
+ g.decision.kind === 'ixAuction' ||
+ g.decision.kind === 'ixTechnology' ||
+ g.decision.kind === 'ixAllyCard' ? (
+
+ ) : g.decision.kind === 'mobileStronghold' ? (
+
+ ) : g.decision.kind === 'ixSubstitution' ? (
+
+ ) : g.decision.kind === 'revivalStop' ? (
+ <>
+
+ {
+ g.players.find(
+ (p) =>
+ g.decision?.kind === 'revivalStop' &&
+ p.id === g.decision.recipient,
+ )?.name
+ }{' '}
+ declared a{' '}
+ {g.decision.revival === 'forces' ? 'force' : 'leader'}{' '}
+ revival. Allow it or use your once-per-game special Karama
+ to prevent their normal force and leader revivals for this
+ turn.
+
+
+ {me.hand
+ ?.filter((c) => c.effect === 'karama')
+ .map((c) => (
+
+ {actionButton(
+ 'Spend special Karama · prevent revival',
+ { type: 'card', card: c.id, mode: 'special' },
+ )}
+
+ ))}
+ {actionButton('Allow this revival', {
+ type: 'decision',
+ decline: true,
+ })}
+ >
+ ) : g.decision.kind === 'choamAudit' ||
+ g.decision.kind === 'choamAuditPayment' ? (
+
+ ) : g.decision.kind === 'strongholdCopy' ? (
+
+ ) : g.decision.kind === 'stoneBurner' ? (
+ <>
+
+ Choose Stone Burner’s effect
+
+ Choose whether both leaders die or surviving leaders
+ contribute no strength. Both choices compare undialed
+ physical force tokens; the choice does not change that
+ comparison. Other weapon effects and traitor precedence
+ still apply.
+
+
+ Read Stone Burner rules
+
+ {actionButton('Kill both leaders', {
+ type: 'decision',
+ event: g.decision.event,
+ mode: 'kill',
+ })}
+ {actionButton('Ignore surviving leader strength', {
+ type: 'decision',
+ event: g.decision.event,
+ mode: 'ignore',
+ })}
+ >
+ ) : g.decision.kind === 'poisonTooth' ? (
+ <>
+
+
+ Choose after reviewing both plans. Activating attacks both
+ leaders, including yours. Chemistry as a defense protects;
+ Snoopers do not. Traitor decisions follow.
+
+ {actionButton('Activate Poison Tooth', {
+ type: 'decision',
+ activate: true,
+ })}
+ {actionButton('Leave it unused', {
+ type: 'decision',
+ activate: false,
+ })}
+ >
+ ) : g.decision.kind === 'faceDance' ? (
+
+ ) : g.decision.kind === 'techToken' ? (
+ <>
+
+ Take one tech token from the defeated faction. Holding all
+ three counts as one stronghold for victory.
+
+
+ {g.decision.choices.map((token) => (
+
+ {actionButton(
+ `Take ${TECH_TOKENS.find((t) => t.id === token)!.name}`,
+ { type: 'decision', token },
+ )}
+
+ ))}
+ >
+ ) : g.decision.kind === 'fullPlanOffer' ? (
+ <>
+
+ You may spend your once-per-game special Karama to inspect
+ either combatant’s full plan. The chosen player commits
+ first, then only you receive the inspection.
+
+
+ {me.hand
+ ?.filter((c) => c.effect === 'karama')
+ .flatMap((c) =>
+ [g.battle!.attacker, g.battle!.defender].map((id) => (
+
+ {actionButton(
+ `Spend Karama · inspect ${g.players.find((p) => p.id === id)?.name}`,
+ {
+ type: 'card',
+ mode: 'special',
+ card: c.id,
+ target: id,
+ },
+ )}
+
+ )),
+ )}
+ {actionButton('Keep special power for later', {
+ type: 'decision',
+ decline: true,
+ })}
+ >
+ ) : g.decision.kind === 'fullPlanRead' ? (
+
+ Restoring the saved battle continuation. Your inspection
+ remains available above; no confirmation is needed.
+
+ ) : g.decision.kind === 'guildShipment' ? (
+ <>
+
+ {
+ g.players.find(
+ (p) =>
+ p.id ===
+ (g.decision?.kind === 'guildShipment'
+ ? g.decision.shipper
+ : ''),
+ )?.name
+ }{' '}
+ declares {g.decision.amount} forces to{' '}
+ {territory(g.decision.territory).name}, sector{' '}
+ {g.decision.sector}. Allow this shipment or spend your
+ once-per-game special Karama to stop it.
+
+
+ {actionButton('Allow shipment', {
+ type: 'decision',
+ allow: true,
+ })}
+ {me.hand
+ ?.filter((c) => c.effect === 'karama')
+ .map((c) => (
+
+ {actionButton('Spend Karama · stop shipment', {
+ type: 'card',
+ mode: 'special',
+ card: c.id,
+ })}
+
+ ))}
+ >
+ ) : g.decision.kind === 'handExchange' ? (
+ <>
+
+ Inspect your combined hand, then return exactly{' '}
+ {g.decision.count} cards to{' '}
+ {
+ g.players.find(
+ (p) =>
+ p.id ===
+ (g.decision?.kind === 'handExchange'
+ ? g.decision.target
+ : ''),
+ )?.name
+ }
+ . You may return cards you just took.
+
+
+ {me.hand?.map((c) => (
+
+
+ setExchangeCards({
+ ...exchangeCards,
+ [c.id]: e.target.checked,
+ })
+ }
+ />
+ {c.name}
+
+ ))}
+ {actionButton(
+ 'Return selected cards',
+ {
+ type: 'decision',
+ returnCards:
+ me.hand
+ ?.filter((c) => exchangeCards[c.id])
+ .map((c) => c.id) ?? [],
+ },
+ me.hand?.filter((c) => exchangeCards[c.id]).length !==
+ g.decision.count,
+ )}
+ >
+ ) : g.decision.kind === 'intrusion' ? (
+ <>
+
+ Another faction entered{' '}
+ {territory(g.decision.territory).name}. You may turn every
+ fighter there into an advisor. A Karama response resolves
+ before the flip.
+
+ {actionButton('Become advisors', {
+ type: 'decision',
+ accept: true,
+ })}
+ {actionButton('Remain fighters', {
+ type: 'decision',
+ accept: false,
+ })}
+ >
+ ) : g.decision.kind === 'advisorBattle' ? (
+ <>
+
+ Before anyone ships, choose where your advisors will fight.
+ Each territory has its own Karama response.
+
+ {g.decision.territories.map((t) => (
+
+ {actionButton(`Prepare in ${territory(t).name}`, {
+ type: 'decision',
+ accept: true,
+ territory: t,
+ })}
+
+ ))}
+ {actionButton('Keep remaining advisors peaceful', {
+ type: 'decision',
+ accept: false,
+ })}
+ >
+ ) : g.decision.kind === 'captureOffer' ? (
+ <>
+
+ You may capture a random eligible surviving leader from the
+ defeated faction. Inspect the captive before choosing
+ whether to retain or execute them.
+
+ {actionButton('Capture a leader', {
+ type: 'decision',
+ accept: true,
+ })}
+ {actionButton('Decline capture', {
+ type: 'decision',
+ accept: false,
+ })}
+ >
+ ) : g.decision.kind === 'capturedLeader' ? (
+ <>
+
+ {leaderName(g.decision.leader)} · strength{' '}
+ {
+ me.leaders.find(
+ (l) =>
+ l.id ===
+ (g.decision?.kind === 'capturedLeader'
+ ? g.decision.leader
+ : ''),
+ )?.strength
+ }
+
+
+ Retain this leader for one battle, or execute them for two
+ spice. A survivor returns to their faction after use. If all
+ your own leaders are dead, a retained captive returns
+ immediately.
+
+ {actionButton('Retain captive', {
+ type: 'decision',
+ mode: 'keep',
+ })}
+ {actionButton('Execute · gain 2 spice', {
+ type: 'decision',
+ mode: 'execute',
+ })}
+ >
+ ) : g.decision.kind === 'guildTiming' ? (
+ <>
+
+ Take your complete shipment and movement now, or let{' '}
+ {
+ g.players.find(
+ (p) =>
+ p.id ===
+ (g.decision?.kind === 'guildTiming'
+ ? g.decision.following
+ : ''),
+ )?.name
+ }{' '}
+ act first. You can decide again before the next player
+ starts.
+
+ {actionButton('Ship and move now', {
+ type: 'decision',
+ take: true,
+ })}
+ {actionButton('Wait', { type: 'decision', take: false })}
+ >
+ ) : g.decision.kind === 'stormLosses' ? (
+ <>
+
+ Lose {g.decision.amount} forces in{' '}
+ {territory(splitLocation(g.decision.key).territory).name},
+ sector {splitLocation(g.decision.key).sector}. Choose how
+ many Fedaykin are among the losses.
+
+ {Array.from(
+ {
+ length: stormLosses!.maxElite - stormLosses!.minElite + 1,
+ },
+ (_, i) => stormLosses!.minElite + i,
+ ).map((elite) => (
+
+ {actionButton(
+ `Lose ${elite} Fedaykin + ${stormLosses!.amount - elite} ordinary`,
+ { type: 'decision', elite },
+ )}
+
+ ))}
+ >
+ ) : g.decision.kind === 'wormPlacement' ? (
+ <>
+
+ An additional worm appeared in this spice blow. Choose a
+ sand territory for its attack.
+
+
+ Sand territory
+ choose(e.target.value)}
+ >
+
+ Choose sand territory
+
+ {TERRITORIES.filter((t) => t.type === 'sand').map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+ {actionButton(
+ 'Place worm',
+ { type: 'decision', accept: true, territory: selected },
+ territory(selected).type !== 'sand',
+ )}
+ {actionButton('Do not place this worm', {
+ type: 'decision',
+ accept: false,
+ })}
+ >
+ ) : g.decision.kind === 'battleLosses' ? (
+ <>
+
+ Choose forces to lose that match your revealed strength and
+ spice support.
+
+ {g.decision.options.map((option, choice) => (
+
+ {actionButton(
+ `Lose ${option.normal} ${me.faction === 'ixians' ? 'suboids' : 'ordinary'} + ${option.elite} ${me.faction === 'ixians' ? 'cyborgs' : 'elite'}`,
+ { type: 'decision', choice },
+ )}
+
+ ))}
+ >
+ ) : g.decision.kind === 'auctionPayment' ? (
+ <>
+
+ You won the auction for {g.auction?.bid} spice. Pay the bid
+ or spend a Karama to take the card without paying spice.
+
+ {actionButton(
+ 'Pay with spice',
+ { type: 'decision', karama: false },
+ (g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available,
+ )}
+ {me.hand
+ ?.filter((c) => canUseAsKarama(g.advanced, me.faction, c))
+ .map((c) => (
+
+ {actionButton(
+ `Spend ${c.name}${c.kind === 'worthless' ? ' as Karama' : ''}`,
+ { type: 'decision', karama: true, card: c.id },
+ )}
+
+ ))}
+ >
+ ) : g.decision.kind === 'advisor' ? (
+ <>
+
+ Another faction has shipped from off-planet. You may send
+ one reserve force to the Polar Sink for free.
+
+ {g.guildAmbassadorAdvisorChoices ? (
+ g.guildAmbassadorAdvisorChoices.choices.map((choice) => {
+ const reasonId = `guild-advisor-${choice.accompany}-${choice.territory}-${choice.sector}`;
+ return (
+
+ {actionButton(
+ choice.accompany
+ ? `Accompany · ${territory(choice.territory).name} · sector ${choice.sector}`
+ : 'Send to Polar Sink',
+ {
+ type: 'decision',
+ accept: true,
+ accompany: choice.accompany,
+ territory: choice.territory,
+ sector: choice.sector,
+ },
+ !!choice.blocked,
+ choice.blocked ? reasonId : undefined,
+ )}
+ {choice.blocked && (
+
+ {choice.blocked}
+
+ )}
+
+ );
+ })
+ ) : (
+ <>
+ {g.advanced &&
+ g.decision.destination &&
+ territory(
+ splitLocation(g.decision.destination).territory,
+ ).sectors.map((s) => (
+
+ {actionButton(
+ `Accompany · sector ${s}`,
+ {
+ type: 'decision',
+ accept: true,
+ accompany: true,
+ sector: s,
+ },
+ s !== 0 && s === g.storm,
+ )}
+
+ ))}
+ {actionButton('Send to Polar Sink', {
+ type: 'decision',
+ accept: true,
+ })}
+ >
+ )}
+ {actionButton('Decline shipment', {
+ type: 'decision',
+ accept: false,
+ })}
+ >
+ ) : g.decision.kind === 'wormProtection' ? (
+ <>
+
+ Shai-Hulud has appeared in{' '}
+ {territory(g.decision.territory).name}. You may protect your
+ ally’s forces from being devoured.
+
+ {actionButton('Protect my ally', {
+ type: 'decision',
+ accept: true,
+ })}
+ {actionButton('Do not protect', {
+ type: 'decision',
+ accept: false,
+ })}
+ >
+ ) : g.decision.kind === 'wormRide' ? (
+ <>
+
+ The Nexus has concluded. Move some or all of your forces
+ from {territory(g.decision.territory).name} to a legal
+ territory. This does not use your normal movement.
+
+ {Object.entries(me.forces)
+ .filter(
+ ([key, n]) =>
+ n > 0 &&
+ splitLocation(key).territory ===
+ (g.decision as { territory: string }).territory,
+ )
+ .map(([key, n]) => (
+
+ From sector {splitLocation(key).sector} · {n} forces
+
+ setRideForces({
+ ...rideForces,
+ [key]: Number(e.target.value),
+ })
+ }
+ />
+
+ ))}
+ {destination}
+ {me.elites &&
+ Object.entries(rideForces)
+ .filter(
+ ([key]) =>
+ splitLocation(key).territory ===
+ (g.decision as { territory: string }).territory,
+ )
+ .map(([key, n]) => (
+
+ setEliteForces({ ...eliteForces, [key]: n })
+ }
+ />
+ ))}
+ {actionButton('Ride Shai-Hulud', {
+ type: 'decision',
+ eliteForces,
+ accept: true,
+ territory: selected,
+ sector,
+ forces: Object.fromEntries(
+ Object.entries(rideForces).filter(
+ ([key]) =>
+ splitLocation(key).territory ===
+ (g.decision as { territory: string }).territory,
+ ),
+ ),
+ })}
+ {actionButton('Stay in this territory', {
+ type: 'decision',
+ accept: false,
+ })}
+ >
+ ) : (
+ <>
+
+ You may keep or discard each card played in this battle.
+ Select the cards you want to discard.
+
+
+ {g.decision.cards.map((id) => {
+ const card = me.hand?.find((c) => c.id === id);
+ return (
+
+
+
+ setDiscardCards({
+ ...discardCards,
+ [id]: e.target.checked,
+ })
+ }
+ />
+ Discard {card?.name ?? id}
+
+ {card && }
+
+ );
+ })}
+
+ {actionButton('Confirm card choices', {
+ type: 'decision',
+ discard: g.decision.cards.filter((id) => discardCards[id]),
+ })}
+ >
+ )}
+ >
+ ) : (
+ <>
+ {PHASES[g.phase]}
+ {g.mentatVictoryPending && (
+
+ Victory will be checked after Mentat actions, the closing
+ market and CHOAM’s final Trip to Gamont opportunity.
+
+ )}
+
+ What happens in this phase?
+ {PHASE_HELP[g.phase]}
+
+ Open phase guide ↗
+
+
+
+
+
+
+ {me.ally && [3, 5].includes(g.phase) && (
+
+
+ Alliance funding · {g.aid.available} spice available
+
+
+ Set aside spice for your ally’s purchases this phase.
+ Unspent spice returns when the phase ends. Your current
+ pledge is {g.aid.pledged}.
+
+
+ New unspent pledge
+ setAidAmount(Number(e.target.value))}
+ />
+
+ {actionButton('Set ally funding', {
+ type: 'pledgeAid',
+ amount: aidAmount,
+ })}
+
+ Ally’s share of your next payment
+ setAllyPayment(e.target.value)}
+ />
+
+
+ Leave blank to use your own spice first.
+
+
+ )}
+ {g.phase === 0 && g.stormPending !== null && (
+ <>
+
+ The storm is set to move {g.stormPending} sectors. Play
+ Weather Control or Family Atomics before confirming.
+
+ {g.stormRevealed && (
+
+ Revealed dials:{' '}
+ {Object.values(g.stormRevealed).join(' + ')}
+
+ )}
+ {actionButton(
+ g.ready.includes(me.id)
+ ? 'Waiting for the table'
+ : 'Confirm storm movement',
+ { type: 'ready' },
+ g.ready.includes(me.id),
+ )}
+ >
+ )}
+ {g.phase === 0 && g.stormPending === null && (
+ <>
+
+ The two storm dialers choose secretly. Their sum moves the
+ storm.
+
+ {g.stormDialers.includes(me.id) &&
+ !g.stormSubmitted.includes(me.id) ? (
+ <>
+
+ Storm dial
+ setStormDial(Number(e.target.value))}
+ />
+
+ {actionButton('Lock storm dial', {
+ type: 'stormDial',
+ amount: stormDialValue,
+ })}
+ >
+ ) : (
+ Waiting for secret storm dials.
+ )}
+ >
+ )}
+ {g.phase === 1 && g.spiceWindow && (
+
+ {g.spiceWindow.amount} spice revealed in{' '}
+ {territory(g.spiceWindow.territory).name}, sector{' '}
+ {g.spiceWindow.sector}.
+ {g.spiceWindow.sector === g.storm
+ ? ' The storm destroys this blow.'
+ : ''}
+ {g.spiceWindow.harvesters
+ ? ` Harvester has been played ${g.spiceWindow.harvesters} time(s).`
+ : ''}
+ {harvesterAvailable(g.spiceWindow)
+ ? ' You may play Harvester before accepting the blow.'
+ : ' This blow is closed to Harvester.'}
+
+ )}
+ {g.phase === 1 && g.nexus && !g.spiceWindow && (
+ <>
+
+ A sandworm has opened a Nexus. Alliances may change now.
+
+
+ Alliance partner
+ setTarget(e.target.value)}
+ >
+ Choose a player
+ {g.players
+ .filter((p) => p.id !== me.id)
+ .map((p) => (
+
+ {p.name}
+ {g.allianceOffers[p.id] === me.id
+ ? ' · Invited you'
+ : ''}
+
+ ))}
+
+
+ {actionButton('Propose / accept alliance', {
+ type: 'alliance',
+ target,
+ })}
+ {me.ally &&
+ actionButton('Break alliance', { type: 'alliance' })}
+ >
+ )}
+ {g.phase === 1 && !g.nexus && !g.spiceWindow && (
+
+ Ready the table to reveal the next spice blow.
+
+ )}
+ {g.phase === 2 && (
+ <>
+
+ {g.charity.multiplier === 0
+ ? 'Inflation cancels charity for every faction this turn.'
+ : g.charity.multiplier === 2
+ ? 'Inflation doubles the amount of charity you receive. Ordinary eligibility still requires fewer than two spice; advanced Bene Gesserit receives four regardless of wealth.'
+ : g.advanced && me.faction === 'beneGesserit'
+ ? 'Receive two spice once per turn. If you already hold two or more, opponents may cancel the faction benefit with Karama.'
+ : 'If you have fewer than two spice, claim charity once to reach two.'}
+
+
+ {g.charity.payer
+ ? `Charity is paid from ${g.players.find((p) => p.id === g.charity.payer)?.name}’s spice.`
+ : 'Charity is paid by the Spice Bank.'}
+
+ {actionButton(
+ g.charity.amount > 0
+ ? `Claim ${g.charity.amount} charity spice`
+ : 'Claim charity',
+ { type: 'charity' },
+ me.charityClaimed ||
+ g.charity.incomePending ||
+ g.charity.amount === 0,
+ )}
+ >
+ )}
+ {g.phase === 3 && g.richeseAuction && (
+
+ )}
+ {g.phase === 3 && !g.richeseAuction && g.auction && (
+ <>
+
+
+ {g.auction.remaining} CARDS REMAIN
+
+
{g.auction.card?.name ?? 'Unknown treachery'}
+ {g.auction.card &&
}
+ {!!g.ixTechnology?.known.length && (
+
+ Your inspected pool · order unknown
+
+ {g.ixTechnology.known.map((c) => (
+
+ {c.name}
+
+
+ ))}
+
+
+ This records the pool you inspected, including cards
+ already auctioned; it does not reveal the next card or
+ other hands.
+
+
+ )}
+
+ {g.auction.bid} spice
+
+
+ {g.auction.active === me.id ? (
+ <>
+
+ Your bid
+
+ setBidDraft({
+ auction: auctionKey,
+ value: Number(e.target.value),
+ })
+ }
+ />
+
+
+ Minimum bid: {minimumBid} spice.
+ {maximumBid < minimumBid &&
+ ' You cannot afford a higher bid; pass this card.'}
+
+ {actionButton(
+ 'Raise bid',
+ {
+ type: 'bid',
+ amount: bidAmount,
+ ...payment,
+ },
+ !Number.isSafeInteger(bidAmount) ||
+ bidAmount > maximumBid,
+ )}
+ {actionButton('Pass this bid', { type: 'passBid' })}
+ >
+ ) : (
+
+ Waiting for{' '}
+ {g.players.find((p) => p.id === g.auction!.active)?.name}.
+
+ )}
+ >
+ )}
+ {me.faction === 'emperor' && me.ally && (
+
+ Share wealth with your ally
+
+ Spice becomes available immediately after the Karama
+ response.
+
+
+ Spice
+ setAidAmount(Number(e.target.value))}
+ />
+
+ {actionButton(
+ 'Share spice',
+ { type: 'emperorGift', amount: aidAmount },
+ aidAmount < 1,
+ )}
+
+ )}
+ {g.phase === 4 && (
+ <>
+ {g.revival.prevented && (
+
+ Tleilaxu prevented your normal force and leader revivals
+ this turn. Ghola treachery and the Emperor’s special
+ Karama remain separate.
+
+ )}
+
+ {g.revival.freeBlocked && (
+
+ La La La prevents your free force revivals for this phase.
+ {me.faction === 'fremen' &&
+ !g.players.some((p) => p.faction === 'tleilaxu')
+ ? ' Fremen cannot purchase normal force revivals without Tleilaxu in the game.'
+ : ' Normal paid revivals remain available.'}
+
+ )}
+
+ {g.revival.limit === 20
+ ? 'No normal force revival limit'
+ : `Up to ${g.revival.limit} force revivals`}{' '}
+ · {g.revival.freeRemaining} free remaining ·{' '}
+ {me.faction === 'fremen' &&
+ !g.players.some((p) => p.faction === 'tleilaxu')
+ ? 'Fremen force revival is free only without Tleilaxu'
+ : me.faction === 'choam' && !g.revival.choamBlocked
+ ? 'One spice per paid force'
+ : 'Standard force prices'}
+ {g.revival.discount
+ ? ' · Tleilaxu half-price discount'
+ : ''}
+
+ {me.faction === 'fremen' &&
+ me.ally &&
+ !g.freeRevival.includes(me.ally) &&
+ actionButton('Grant ally three free revivals', {
+ type: 'grantRevival',
+ })}
+ {g.freeRevival.includes(me.id) && !g.revival.freeBlocked && (
+
+ Fremen allow you up to three free force revivals this
+ phase.
+
+ )}
+ {amountInput}
+ {me.faction === 'ixians' && (
+
+ Use free revival for
+
+ setFreeCyborgFirst(e.target.value === 'cyborg')
+ }
+ >
+ Cyborgs first
+ Suboids first
+
+
+ Paid cyborgs cost three spice; suboids cost two. Cyborgs
+ do not share the one-per-turn Sardaukar/Fedaykin limit.
+
+
+ )}
+ {me.faction === 'emperor' &&
+ me.ally &&
+ (() => {
+ const ally = g.players.find((p) => p.id === me.ally)!;
+ const cost = paidForceRevivalCost(
+ ally,
+ amount,
+ ally.elites ? allyEliteAmount : 0,
+ );
+ return (
+
+
Extra revivals for {ally.name}
+ {ally.elites && (
+
+ )}
+
+ You pay {cost} spice. These forces use your ally’s
+ separate extra-revival allowance.
+
+ {actionButton(
+ 'Pay for extra ally revivals',
+ {
+ type: 'emperorRevival',
+ amount,
+ elite: ally.elites ? allyEliteAmount : 0,
+ },
+ cost > (me.spice ?? 0) ||
+ amount > 3 - (g.emperorExtra[ally.id] ?? 0),
+ )}
+
+ );
+ })()}
+ {g.revival.kwisatz &&
+ actionButton(
+ `Revive Kwisatz Haderach · ${g.revival.kwisatz.cost} spice`,
+ {
+ type: 'reviveKwisatz',
+ },
+ !g.revival.kwisatz.affordable,
+ )}
+ {(g.emperorExtra[me.id] ?? 0) > 0 && (
+
+ The Emperor revived {g.emperorExtra[me.id]} extra forces
+ for you. Your normal revival allowance is separate.
+
+ )}
+ {actionButton(
+ 'Revive forces',
+ {
+ type: 'revive',
+ amount,
+ elite: eliteAmount,
+ freeElite:
+ me.faction === 'ixians' && !freeCyborgFirst
+ ? Math.max(
+ 0,
+ Math.min(amount, g.revival.freeRemaining) -
+ (amount - eliteAmount),
+ )
+ : undefined,
+ },
+ g.revival.prevented ||
+ !me.tanks ||
+ amount > g.revival.forcesRemaining,
+ )}
+ {me.faction === 'fremen' &&
+ amount > g.revival.forcesRemaining ? (
+
+ Choose no more than {g.revival.forcesRemaining} available
+ free force revivals. Emperor-funded extras and card
+ effects are separate.
+
+ ) : (
+
+ Selected force revival costs{' '}
+ {
+ forceRevivalPrice(
+ me.faction,
+ amount,
+ eliteAmount,
+ Math.min(amount, g.revival.freeRemaining),
+ g.revival.discount,
+ me.faction === 'ixians' && !freeCyborgFirst
+ ? Math.max(
+ 0,
+ Math.min(amount, g.revival.freeRemaining) -
+ (amount - eliteAmount),
+ )
+ : undefined,
+ g.revival.choamBlocked,
+ ).cost
+ }{' '}
+ spice.
+
+ )}
+ {g.revival.leaders.length > 0
+ ? (() => {
+ const selectedRevival = g.revival.leaders.find(
+ (choice) => choice.id === leader,
+ );
+ return (
+ <>
+
+ Leader
+ setLeader(e.target.value)}
+ >
+
+ Choose an eligible leader
+
+ {g.revival.leaders.map((choice) => (
+
+ {choice.name} · {choice.cost} spice
+ {choice.early ? ' · early revival' : ''}
+ {!choice.affordable
+ ? ' · insufficient spice'
+ : ''}
+
+ ))}
+
+
+ {actionButton(
+ 'Revive leader',
+ {
+ type: 'reviveLeader',
+ leader: selectedRevival?.id ?? '',
+ },
+ !selectedRevival?.affordable,
+ )}
+ >
+ );
+ })()
+ : me.leaders.some(
+ (l) => l.dead && l.faction === me.faction,
+ ) && (
+
+ {me.leaderRevived && me.faction !== 'tleilaxu'
+ ? 'You have used your one leader revival for this phase.'
+ : 'No native leader is currently eligible for normal revival.'}
+
+ )}
+ >
+ )}
+ {g.phase === 5 && (
+
+ )}
+ {g.phase === 5 &&
+ me.faction === 'choam' &&
+ g.choamMovementBonus > 0 && (
+
+ Kulon: +{g.choamMovementBonus} territory of movement range
+ this turn. Storm and entry rules still apply.
+
+ )}
+ {g.phase === 5 &&
+ g.balisetRestrictions
+ .filter((b) => b.player === me.id)
+ .map((b) => (
+
+ Baliset: movement into{' '}
+ {
+ gameTerritories(g).find((t) => t.id === b.territory)
+ ?.name
+ }{' '}
+ is blocked while CHOAM occupies it. Shipment remains
+ possible.
+
+ ))}
+ {g.phase === 5 &&
+ me.faction === 'fremen' &&
+ me.fremenMovementBlocked && (
+
+ Karama limited this move to one territory. Your forces have
+ not moved yet. Available ornithopters still allow their
+ normal range; a later extra move can use your two-territory
+ advantage again.
+
+ )}
+ {g.phase === 5 && me.faction === 'ixians' && (
+
+ Select a cyborg to take accompanying suboids two territories.{' '}
+
+ {me.ixMovementBlocked &&
+ ' Cyborg speed was canceled for this move; use one territory or available ornithopters.'}
+
+ )}
+ {g.phase === 5 &&
+ (g.active === me.id ? (
+ <>
+ {g.advanced &&
+ me.faction === 'ixians' &&
+ !me.specialKaramaUsed &&
+ g.mobileStronghold?.location &&
+ (me.forces['hidden_mobile_stronghold:0'] ?? 0) > 0 &&
+ me.hand
+ ?.filter((c) => c.effect === 'karama')
+ .slice(0, 1)
+ .map((c) => (
+
+
+ Special Karama: relocate stronghold
+
+
+
+ ))}
+ {destination}
+ {amountInput}
+ {g.karamaShipping?.player === me.id && (
+
+ Karama shipment: pay Guild rates to the bank. Choose
+ your shipment below.
+
+ )}
+ {!me.shipped && (
+ <>
+
+ 0
+ }
+ onClick={() =>
+ act({
+ type: 'ship',
+ elite: eliteAmount,
+ ...payment,
+ territory: selected,
+ sector,
+ amount,
+ })
+ }
+ >
+ Ship from reserves
+
+ >
+ )}
+ {sourceInput}
+ {markerAtSource && ownNoField && (
+
+
+ setMoveNoField(event.target.checked)
+ }
+ />
+ Include concealed No-Field from sector{' '}
+ {ownNoField.location.sector}
+
+ )}
+ {includesNoField && (
+
+ Moving {markerPhysicalTotal} physical forces and one
+ concealed No-Field. Choose zero physical forces to move
+ only the marker. Its hidden value stays unchanged.
+
+ )}
+
+ setCombineSectors(e.target.checked)}
+ />
+ Combine forces from this territory’s sectors
+
+ {combineSectors &&
+ source &&
+ Object.entries(me.forces)
+ .filter(
+ ([key, n]) =>
+ n > 0 &&
+ splitLocation(key).territory ===
+ splitLocation(source).territory,
+ )
+ .map(([key, n]) => (
+
+ Sector {splitLocation(key).sector} · {n} forces
+
+ setMoveForces({
+ ...moveForces,
+ [key]: Number(e.target.value),
+ })
+ }
+ />
+
+ ))}
+ {combineSectors &&
+ me.elites &&
+ Object.entries(me.forces)
+ .filter(
+ ([key]) =>
+ splitLocation(key).territory ===
+ splitLocation(source).territory,
+ )
+ .map(([key]) => (
+
+ setEliteForces({ ...eliteForces, [key]: n })
+ }
+ />
+ ))}
+ {g.advanced &&
+ isAdvisor(me, splitLocation(source).territory) && (
+
+
+ setMoveAsFighters(e.target.checked)
+ }
+ />
+ Request a flip to fighters after entering an occupied
+ territory
+
+ )}
+ {!g.ornithopter?.active && (
+ <>
+ {actionButton(
+ includesNoField
+ ? 'Move selected forces and No-Field'
+ : 'Move forces',
+ {
+ type: 'move',
+ fighters:
+ moveAsFighters &&
+ isAdvisor(me, splitLocation(source).territory),
+ ...movementGroup,
+ territory: selected,
+ sector,
+ ...(includesNoField ? {} : { amount }),
+ },
+ !source ||
+ canceledFremenRouteBlocked ||
+ selectedDestinationInStorm ||
+ (me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
+ (includesNoField
+ ? !validMarkerGroup
+ : (me.forces[source] ?? 0) === 0),
+ selectedDestinationInStorm
+ ? 'movement-storm-unavailable'
+ : canceledFremenRouteBlocked
+ ? 'movement-range-unavailable'
+ : undefined,
+ )}
+ {canceledFremenRouteBlocked &&
+ !selectedDestinationInStorm && (
+
+ This route is blocked or beyond your available
+ range. Choose a reachable destination within one
+ territory, or use available ornithopters.
+
+ )}
+ {selectedDestinationInStorm && (
+
+ Movement unavailable: sector {sector} of{' '}
+ {territory(selected).name} is in the storm. Choose a
+ different sector or territory.
+
+ )}
+ >
+ )}
+ = (me.movesAllowed ?? 1) ||
+ (includesNoField
+ ? !validMarkerGroup
+ : (me.forces[source] ?? 0) === 0)
+ ? null
+ : {
+ type: 'move',
+ fighters:
+ moveAsFighters &&
+ isAdvisor(me, splitLocation(source).territory),
+ ...movementGroup,
+ territory: selected,
+ sector,
+ ...(includesNoField ? {} : { amount }),
+ }
+ }
+ />
+
+ {(me.moved ?? 0) < (me.movesAllowed ?? 1)
+ ? `${(me.movesAllowed ?? 1) - (me.moved ?? 0)} movement remaining.`
+ : 'Movement complete. Finish your turn when your shipment is also settled.'}
+
+ {transportPreview && (
+ <>
+
+ {actionButton(
+ 'Guild transport',
+ transportAction,
+ transportPreview.unavailableReasons.length > 0,
+ 'guild-transport-quote',
+ )}
+ {southernTransportPreview && (
+ <>
+
+ {actionButton(
+ 'Cross-ship southern reserves',
+ southernTransportAction,
+ southernTransportPreview.unavailableReasons
+ .length > 0,
+ 'southern-transport-quote',
+ )}
+ >
+ )}
+ {returnTransportPreview && (
+ <>
+
+ {actionButton(
+ 'Return to reserves',
+ returnTransportAction,
+ returnTransportPreview.unavailableReasons.length >
+ 0,
+ 'return-transport-quote',
+ )}
+ >
+ )}
+ >
+ )}
+ {actionButton('Finish shipment & movement', {
+ type: 'endMovement',
+ })}
+ >
+ ) : (
+
+ Waiting for {g.players.find((p) => p.id === g.active)?.name}{' '}
+ to ship and move.
+
+ ))}
+ {g.phase === 6 && !g.battle && g.active === me.id && (
+ <>
+
+ Select a disputed territory on the map, then choose your
+ opponent.
+
+
+ Opponent
+ setTarget(e.target.value)}
+ >
+ Choose opponent
+ {g.players
+ .filter((p) => p.id !== me.id)
+ .map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+ {actionButton('Begin battle', {
+ type: 'chooseBattle',
+ territory: selected,
+ target,
+ })}
+ >
+ )}
+ {g.phase === 8 && me.faction === 'choam' && (
+ <>
+ Inflation
+ {g.inflationUsed ? (
+ Your Inflation token has been used.
+ ) : g.inflationAttempted ? (
+
+ Placement was canceled. You may try again at a later
+ Mentat Pause.
+
+ ) : (
+ <>
+
+ Set next turn’s charity. The token flips at the next
+ Mentat Pause, then leaves the game at the following one.
+ You may save it by marking ready.
+
+ {actionButton('Double, then cancel', {
+ type: 'choamInflation',
+ side: 'double',
+ })}
+ {actionButton('Cancel, then double', {
+ type: 'choamInflation',
+ side: 'cancel',
+ })}
+ >
+ )}
+ >
+ )}
+ {g.battle && (
+ <>
+
+
+ {territory(g.battle.territory).name}
+
+ {g.battle.eliteBlocked.length > 0 && (
+
+ Karama:{' '}
+ {g.battle.eliteBlocked
+ .map((id) => g.players.find((p) => p.id === id)?.name)
+ .join(', ')}{' '}
+ counts elite tokens as ordinary forces in this battle.
+
+ )}
+ {g.battle.fremenSupportBlocked && (
+
+ Karama: Fremen must support tokens with spice for full
+ strength in this battle.
+
+ )}
+ {g.battle.revealed && }
+ {(!g.battle.preLeader || g.battle.preLeader.closed) && (
+ {
+ setDial(plan.dial);
+ setSupport(plan.support);
+ setAllyPayment(
+ plan.allyPayment === undefined
+ ? ''
+ : String(plan.allyPayment),
+ );
+ setLeader(plan.leader ?? '');
+ setWeapon(plan.weapon ?? '');
+ setDefense(plan.defense ?? '');
+ setKwisatz(!!plan.kwisatz);
+ }}
+ />
+ )}
+ {g.battle.preLeader && !g.battle.preLeader.closed ? (
+
+ ) : g.battle.preparation ? (
+
+ ) : g.battle.fullPlan &&
+ !g.battle.submitted.includes(g.battle.fullPlan.target) &&
+ g.battle.fullPlan.target !== me.id ? (
+
+ Waiting for{' '}
+ {
+ g.players.find(
+ (p) => p.id === g.battle!.fullPlan!.target,
+ )?.name
+ }{' '}
+ to commit the plan requested by Atreides.
+
+ ) : [g.battle.attacker, g.battle.defender].includes(me.id) ? (
+ g.battle.revealed ? (
+ <>
+ {!g.battle.traitorSubmitted.includes(me.id) && (
+ <>
+ {actionButton(
+ 'Reveal traitor',
+ {
+ type: 'traitorCall',
+ call: true,
+ },
+ !canRevealTraitor,
+ )}
+ {!canRevealTraitor && (
+ {noTraitorReason}
+ )}
+ {actionButton('No traitor — resolve', {
+ type: 'traitorCall',
+ call: false,
+ })}
+ >
+ )}
+ >
+ ) : (
+ <>
+ {g.battle.insight && (
+
+ {committed
+ ? 'Your committed element'
+ : 'Prescience'}
+ : {g.battle.insight.field} ={' '}
+ {g.battle.insight.label}
+
+ )}
+ {g.battle.voice && (
+
+ Voice:{' '}
+ {g.battle.voice.must ? 'must play' : 'cannot play'}{' '}
+ {battleCardLabel(g.battle.voice.kind)}.
+
+ )}
+ {!g.battle.submitted.includes(me.id) ? (
+ <>
+
+ Forces dialed
+ {me.faction === 'ixians' && (
+
+ )}
+
+
+
+ {ownNoField &&
+ g.battle.noFieldPlayers.includes(me.id)
+ ? `Your concealed No-Field can reveal ${battleForces} physical forces from your current reserves. This estimate is private.`
+ : `You have ${battleForces} fighting forces here.`}
+
+ {g.advanced &&
+ (me.faction !== 'fremen' ||
+ g.battle.fremenSupportBlocked) && (
+
+ Spice support
+ {strongholdBankSupport > 0 && (
+
+ {' '}
+ · Bank pays{' '}
+ {Math.min(
+ battleSupport,
+ strongholdBankSupport,
+ )}
+ ; personal and allied payment{' '}
+ {Math.max(
+ 0,
+ battleSupport - strongholdBankSupport,
+ )}
+
+ )}
+
+ setSupport(Number(e.target.value))
+ }
+ />
+
+ )}
+ {g.advanced && g.aid.available > 0 && (
+ <>
+
+ CHOAM has reserved {g.aid.available} spice for
+ your support. Leave its share blank to cover
+ only your shortfall.
+
+
+ CHOAM’s share of this support payment
+
+ setAllyPayment(e.target.value)
+ }
+ />
+
+ >
+ )}
+
+ Leader
+ setLeader(e.target.value)}
+ >
+ No leader
+ {me.leaders
+ .filter(
+ (l) =>
+ !l.dead &&
+ controlsLeader(me, l) &&
+ (!l.usedAt ||
+ l.usedAt === g.battle?.territory),
+ )
+ .map((l) => (
+
+ {l.name} · {leaderStrengthLabel(l)}
+
+ ))}
+ {me.hand
+ ?.filter((c) => c.kind === 'hero')
+ .map((c) => (
+
+ {c.name} · 0
+
+ ))}
+
+
+ {[
+ ['Weapon', weapon, setWeapon],
+ ['Defense', defense, setDefense],
+ ].map(([name, v, fn]) => (
+
+ {String(name)}{' '}
+
+
+ (fn as (s: string) => void)(e.target.value)
+ }
+ >
+ None
+ {me.hand
+ ?.filter((c) =>
+ name === 'Defense'
+ ? isDefenseCard(c)
+ : isWeaponCard(c),
+ )
+ .map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+ ))}
+ {selectedStone && (
+
+
+ Stone Burner compares undialed physical
+ tokens, not dialed strength. Its leader effect
+ is chosen after both plans are revealed.
+
+
+ {stonePlanReason && (
+
+ {stonePlanReason}
+
+ )}
+
+ )}
+ {me.leaders.some((l) => l.capturedBy) && (
+
+ {me.leaders
+ .filter((l) => l.capturedBy)
+ .map(
+ (l) =>
+ `${l.name}: ${l.capturedBy === me.id ? 'your captive' : 'captured by Harkonnen'}`,
+ )
+ .join(' · ')}
+
+ )}
+ {me.kwisatz && (
+
+ setKwisatz(e.target.checked)}
+ disabled={
+ !me.kwisatz.active ||
+ me.kwisatz.dead ||
+ g.battle.kwisatzBlocked ||
+ (!!me.kwisatz.usedAt &&
+ me.kwisatz.usedAt !== g.battle.territory)
+ }
+ />
+ Use Kwisatz Haderach (+2; prevents traitors)
+
+ )}
+ {g.battle.fullPlan?.target === me.id && (
+
+ This commits your complete plan for Atreides’
+ private inspection. Your opponent’s plan stays
+ hidden until the public reveal.
+
+ )}
+ {actionButton(
+ g.battle.fullPlan?.target === me.id
+ ? 'Commit plan for inspection'
+ : 'Seal battle plan',
+ {
+ type: 'battlePlan',
+ ...(g.aid.available > 0 && allyPayment !== ''
+ ? { allyPayment: Number(allyPayment) }
+ : {}),
+ support:
+ me.faction === 'fremen' &&
+ !g.battle.fremenSupportBlocked
+ ? 0
+ : battleSupport,
+ kwisatz:
+ kwisatz &&
+ !!me.kwisatz?.active &&
+ !me.kwisatz.dead &&
+ !g.battle.kwisatzBlocked &&
+ (!me.kwisatz.usedAt ||
+ me.kwisatz.usedAt === g.battle.territory),
+ dial: battleDial,
+ leader,
+ weapon,
+ defense,
+ ...(committed
+ ? { [committed.field]: committed.value }
+ : {}),
+ },
+ !!stonePlanReason,
+ )}
+ >
+ ) : (
+
+ Your plan is sealed. Waiting for your opponent.
+
+ )}
+ >
+ )
+ ) : g.battle.revealed &&
+ g.battle.traitorVoters.includes(me.id) ? (
+ <>
+
+ Your ally is fighting. You may reveal a traitor against
+ their opponent.
+
+ {!g.battle.traitorSubmitted.includes(me.id) ? (
+ <>
+ {actionButton(
+ 'Reveal ally’s opponent as traitor',
+ {
+ type: 'traitorCall',
+ call: true,
+ },
+ !canRevealTraitor,
+ )}
+ {!canRevealTraitor && (
+ {noTraitorReason}
+ )}
+ {actionButton('Decline traitor support', {
+ type: 'traitorCall',
+ call: false,
+ })}
+ >
+ ) : (
+
+ Waiting for the other traitor decisions.
+
+ )}
+ >
+ ) : (
+
+ {g.battle.revealed
+ ? 'The table is completing traitor decisions.'
+ : 'The combatants are preparing their plans.'}
+
+ )}
+ >
+ )}
+ {[1, 2, 4, 7, 8].includes(g.phase) &&
+ actionButton(
+ g.ready.includes(me.id)
+ ? 'Waiting for the table'
+ : g.spiceWindow
+ ? 'Accept spice blow'
+ : 'Ready for next phase',
+ { type: 'ready' },
+ g.ready.includes(me.id),
+ )}
+ >
+ )}
+
+
+ {g.inflation && (
+
+ Inflation · {g.inflation.side === 'double' ? 'DOUBLE' : 'CANCEL'} ·{' '}
+ {g.phase === 8 && g.inflation.updatedTurn === g.turn
+ ? 'Next turn’s charity'
+ : 'This turn’s charity'}
+ {g.inflation.side === 'double' ? ' · Bribes prohibited' : ''}
+ {g.inflation.flipped
+ ? ' · Removed at the next Mentat Pause'
+ : ' · Flips at the next Mentat Pause'}
+
+ )}
+
+
+
+ {(['hand', 'leaders', 'log', 'rules'] as const).map((p) => (
+ setPanel(p)}
+ key={p}
+ >
+ {p === 'hand'
+ ? `Your private hand · ${me.hand?.length ?? 0} / ${me.handLimit}`
+ : p === 'leaders'
+ ? 'Your leaders'
+ : p === 'log'
+ ? 'Table chronicle'
+ : 'Rules coverage'}
+
+ ))}
+
+ {panel === 'hand' ? (
+ <>
+
+
+
+
+
+
+
+
+
+ {me.hand?.map((c) => {
+ const richese = richeseCardDefinition(c);
+ const presentation = cardPresentation(c);
+ const block =
+ (richese?.card.effect === 'distrans'
+ ? 'Use the Distrans transfer panel above to choose a recipient and another card.'
+ : richese?.card.effect === 'nullentropyBox'
+ ? 'Use the Nullentropy Box panel above to begin the paid search.'
+ : richeseCardActionBlock(c)) ??
+ (canUseAsKarama(g.advanced, me.faction, c)
+ ? g.response
+ ? !g.responseControls?.cancelCards.includes(c.id)
+ ? 'This card cannot cancel the current power.'
+ : null
+ : g.decision
+ ? 'Finish the pending decision before taking an auction card.'
+ : g.phase !== 3 || !g.auction || g.richeseAuction
+ ? 'The Karama purchase option needs an ordinary auction in progress.'
+ : (me.hand?.length ?? 0) >= me.handLimit
+ ? 'Your hand must have room before a Karama auction purchase.'
+ : null
+ : null);
+ const gholaBlock =
+ c.effect !== 'ghola'
+ ? null
+ : !g.ghola.available
+ ? g.ghola.reason
+ : !g.ghola.cards.includes(c.id)
+ ? 'This card is reserved for the Black Market auction.'
+ : leader
+ ? (
+ leader === 'kwisatz'
+ ? g.ghola.kwisatz
+ : g.ghola.leaders.some((l) => l.id === leader)
+ )
+ ? null
+ : 'Choose a currently eligible leader.'
+ : !Number.isSafeInteger(amount) ||
+ amount < 1 ||
+ amount > g.ghola.maxForces
+ ? `Choose between 1 and ${g.ghola.maxForces} eligible forces.`
+ : !Number.isSafeInteger(eliteAmount) ||
+ eliteAmount <
+ Math.max(
+ 0,
+ amount -
+ (me.tanks - (me.elites?.tanks ?? 0)),
+ ) ||
+ eliteAmount >
+ Math.min(amount, g.ghola.eliteRemaining)
+ ? 'Choose an eligible number of elite forces.'
+ : null;
+ const availability =
+ block || gholaBlock
+ ? {
+ available: false as const,
+ reason: block || gholaBlock!,
+ }
+ : ordinaryCardAvailability(
+ g,
+ c.id,
+ c.effect === 'weather'
+ ? { amount: weatherDistance }
+ : undefined,
+ );
+ return (
+
+
+ {richese
+ ? presentation.category
+ : battleCardLabel(c.kind)}
+
+ {c.name}
+
+
+ {availability && !availability.available && (
+
+ {availability.reason}
+
+ )}
+ {!availability &&
+ g.status === 'setup' &&
+ !g.response &&
+ c.effect !== 'truthtrance' && (
+
+ Finish setup before using this card.
+
+ )}
+
+ {richese
+ ? presentation.role === 'weapon'
+ ? '†'
+ : presentation.role === 'defense'
+ ? '◇'
+ : '✧'
+ : c.kind === 'special'
+ ? '✧'
+ : c.kind === 'shield' || c.kind === 'snooper'
+ ? '◇'
+ : '†'}
+
+ {c.effect === 'truthtrance' && (
+ <>
+
+ Ask another player a public yes/no question, including
+ during another player’s decision. Competing plays
+ follow storm order.
+
+ act({ type: 'card', card: c.id })}
+ >
+ Declare Truthtrance
+
+ {(me.hand?.filter(
+ (other) => other.effect === 'truthtrance',
+ ).length ?? 0) > 1 && (
+
+ act({
+ type: 'card',
+ card: c.id,
+ cards: me
+ .hand!.filter(
+ (other) =>
+ other.effect === 'truthtrance' &&
+ other.id !== c.id,
+ )
+ .map((other) => other.id),
+ })
+ }
+ >
+ Declare both Truthtrances
+
+ )}
+ >
+ )}
+ {c.effect === 'amal' && (
+
+ At a phase opening, every faction returns half its
+ available spice to the bank, rounded up. Play before
+ everyone passes.
+
+ )}
+ {c.effect === 'thumper' && (
+
+ Play before the first spice draw to resolve Shai-Hulud
+ without drawing a card. Fremen protection, rides, Nexus
+ and a waiting Sandtrout apply.
+
+ )}
+ {c.effect === 'ghola' && (
+ <>
+
+ Forces to revive (or choose a leader)
+ setAmount(Number(e.target.value))}
+ />
+
+ {me.elites && (
+
+ )}
+
+ Revival target
+ setLeader(e.target.value)}
+ >
+ Forces
+ {g.ghola.leaders.map((l) => (
+
+ {l.name}
+
+ ))}
+ {g.ghola.kwisatz && (
+ Kwisatz Haderach
+ )}
+
+
+ >
+ )}
+ {g.advanced &&
+ c.effect === 'karama' &&
+ !me.specialKaramaUsed &&
+ me.faction === 'harkonnen' &&
+ g.phase === 3 && (
+ <>
+
+ Once per game: take up to four unseen cards, inspect
+ them, then return the same number.
+
+
+ Choose a player
+ setTarget(e.target.value)}
+ >
+ Choose hand
+ {g.players
+ .filter(
+ (p) =>
+ p.id !== me.id && (p.handCount ?? 0) > 0,
+ )
+ .map((p) => (
+
+ {p.name} · {p.handCount} cards
+
+ ))}
+
+
+
+ Cards to take
+
+ setAmount(Number(e.target.value))
+ }
+ />
+
+
+ {actionButton(
+ 'Use special Karama · exchange',
+ {
+ type: 'card',
+ mode: 'special',
+ card: c.id,
+ target,
+ amount,
+ },
+ !target,
+ )}
+ >
+ )}
+ {g.advanced &&
+ c.effect === 'karama' &&
+ !me.specialKaramaUsed &&
+ me.faction === 'fremen' &&
+ g.phase === 1 && (
+ <>
+
+ Once per game: summon Shai-Hulud in a sand
+ territory. Resolve protection now; Nexus and rides
+ follow the spice blow.
+
+
+ Sand territory
+ t.id === selected && t.type === 'sand',
+ )
+ ? selected
+ : ''
+ }
+ onChange={(e) => {
+ if (e.target.value) choose(e.target.value);
+ }}
+ >
+ Choose territory
+ {TERRITORIES.filter((t) => t.type === 'sand').map(
+ (t) => (
+
+ {t.name}
+
+ ),
+ )}
+
+
+
+ {actionButton(
+ 'Spend Karama · summon Shai-Hulud',
+ {
+ type: 'card',
+ mode: 'special',
+ card: c.id,
+ territory: selected,
+ },
+ !TERRITORIES.some(
+ (t) => t.id === selected && t.type === 'sand',
+ ),
+ )}
+ >
+ )}
+ {g.advanced &&
+ c.effect === 'karama' &&
+ !me.specialKaramaUsed &&
+ me.faction === 'emperor' &&
+ g.phase === 4 && (
+ <>
+
+ Once per game: revive up to three forces or one
+ leader for free.
+
+
+ Forces to revive
+
+ setAmount(Number(e.target.value))
+ }
+ />
+
+ {me.elites && (
+
+ )}
+
+ {actionButton(
+ 'Use special Karama · forces',
+ {
+ type: 'card',
+ mode: 'special',
+ card: c.id,
+ amount,
+ elite: eliteAmount,
+ },
+ !me.tanks,
+ )}
+ {me.leaders
+ .filter(
+ (l) => l.dead && !l.capturedBy && !l.gholaBy,
+ )
+ .map((l) => (
+
+ {actionButton(
+ `Revive ${l.name} with special Karama`,
+ {
+ type: 'card',
+ mode: 'special',
+ card: c.id,
+ leader: l.id,
+ },
+ )}
+
+ ))}
+ >
+ )}
+ {c.effect === 'weather' && (
+
+ Storm distance
+
+ setWeatherDistance(Number(e.target.value))
+ }
+ />
+
+ )}
+ {canUseAsKarama(g.advanced, me.faction, c) &&
+ g.phase === 5 &&
+ g.active &&
+ !g.players.find((p) => p.id === g.active)?.shipped && (
+
+ act({
+ type: 'card',
+ card: c.id,
+ mode: 'shipment',
+ target: g.active,
+ })
+ }
+ >
+ Guild rates for{' '}
+ {g.players.find((p) => p.id === g.active)?.name}
+
+ )}
+ {c.effect !== 'truthtrance' &&
+ (c.kind === 'special' ||
+ canUseAsKarama(g.advanced, me.faction, c)) && (
+
+ act({
+ type: 'card',
+ elite: eliteAmount,
+ card: c.id,
+ mode: canUseAsKarama(g.advanced, me.faction, c)
+ ? g.response
+ ? 'cancel'
+ : 'purchase'
+ : undefined,
+ amount:
+ c.effect === 'weather'
+ ? weatherDistance
+ : c.effect === 'ghola'
+ ? amount
+ : dial,
+ leader: leader || undefined,
+ })
+ }
+ >
+ {canUseAsKarama(g.advanced, me.faction, c)
+ ? g.response
+ ? 'Cancel this power'
+ : 'Take current auction card'
+ : 'Play card'}
+
+ )}
+
+ );
+ })}
+ {!me.hand?.length && (
+
+ {setupStage
+ ? 'Your starting Treachery cards remain undealt until all starting force placement is complete.'
+ : g.status === 'lobby'
+ ? 'Your opening cards are dealt after starting force placement.'
+ : 'Your Treachery hand is empty.'}
+
+ )}
+
+ {!!me.faceDancers?.length && (
+
+
Your Face Dancers
+ {me.faceDancers.map((c) => (
+
+
+ {leaderName(c.leader)} ·{' '}
+ {c.revealed ? 'Revealed' : 'Secret'}
+
+ {inspectIdentity(c.leader, 'faceDancer')}
+ {g.phase === 8 &&
+ !c.revealed &&
+ !me.faceDancerReplaced &&
+ actionButton(
+ 'Replace this Face Dancer',
+ { type: 'replaceFaceDancer', leader: c.leader },
+ !!g.phaseOpening || !!g.response || !!g.decision,
+ )}
+
+ ))}
+
+ )}
+ {!!me.traitors?.length && (
+
+
YOUR TRAITORS
+ {me.traitors.map((id) => (
+
+ {leaderName(id)}
+ {inspectIdentity(id, 'traitor')}
+
+ ))}
+
+ )}
+ >
+ ) : panel === 'leaders' ? (
+ <>
+
+ Inspect your leader identities here. Choose and commit a leader
+ through the battle controls when making a plan.
+
+
+ {me.leaders.map((l) => (
+
+
+ Leader strength
+ {leaderStrengthLabel(l)}
+ >
+ }
+ fallback={
+
+ Leader strength
+ {leaderStrengthLabel(l)}
+
+ }
+ />
+ {faction(l.faction).name}
+ {l.name}
+
+ {l.dead
+ ? 'In the tanks'
+ : !controlsLeader(me, l)
+ ? 'Controlled by another faction'
+ : l.usedAt
+ ? 'Used in a battle this turn'
+ : 'Ready'}
+
+
+
+ ))}
+
+ >
+ ) : panel === 'log' ? (
+
+ {[...g.log].reverse().map((e) => (
+
+ {String(e.seq).padStart(3, '0')}
+ {e.text.replace(
+ /used Karama to cancel fremenMovement\.$/,
+ 'used Karama to cancel Fremen movement.',
+ )}
+
+ ))}
+
+ ) : (
+
+
Rules verification in progress
+
+ This build implements a playable portion of the basic game. It is
+ not yet a fully rules-compliant edition.
+
+
+ Still in progress: expansion factions and modules, advanced rules,
+ several special cards, optional faction reactions, ally support,
+ auction deadlines, and a printed-board connectivity audit.
+
+
+ Open the rules coverage ledger
+
+
+ )}
+
+
+ {g.strongholdCards && (
+
+
+ Inspect Stronghold Cards
+
+
+
+ )}
+
+ Unofficial fan implementation · Development build
+ Board geometry: Truthsayer / MIT
+
+
+ );
+}
diff --git a/components/help-tip.tsx b/components/help-tip.tsx
new file mode 100644
index 00000000..a328e72e
--- /dev/null
+++ b/components/help-tip.tsx
@@ -0,0 +1,30 @@
+'use client';
+import { CircleHelp } from 'lucide-react';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import { HELP } from '@/game/reference';
+
+export function HelpTip({ topic }: { topic: keyof typeof HELP }) {
+ return (
+
+
+
+
+
+ {HELP[topic]}
+
+
+ );
+}
diff --git a/components/ix-substitution.tsx b/components/ix-substitution.tsx
new file mode 100644
index 00000000..91c53454
--- /dev/null
+++ b/components/ix-substitution.tsx
@@ -0,0 +1,84 @@
+'use client';
+import { useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { splitLocation } from '@/game/board';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+export function IxSubstitution({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const [sources, setSources] = useState>({});
+ const [recover, setRecover] = useState>({});
+ if (g.decision?.kind !== 'ixSubstitution') return null;
+ const d = g.decision;
+ const me = g.players.find((p) => p.id === g.me)!;
+ const available = Object.entries(me.forces)
+ .filter(([key]) => splitLocation(key).territory === d.territory)
+ .map(
+ ([key, count]) => [key, count - (me.elites?.forces[key] ?? 0)] as const,
+ )
+ .filter(([, count]) => count > 0);
+ const total = (value: Record) =>
+ Object.values(value).reduce((a, b) => a + b, 0);
+ return (
+ <>
+
+ Exchange surviving suboids, one for one, to retain cyborgs lost in this
+ battle. Retained cyborgs return to their casualty sectors. Opponents may
+ cancel this substitution with Karama.
+
+ Suboids to sacrifice
+ {available.map(([key, max]) => (
+
+ Sector {splitLocation(key).sector} · {max} suboids available
+
+ setSources({ ...sources, [key]: Number(e.target.value) })
+ }
+ />
+
+ ))}
+ Cyborgs to retain
+ {Object.entries(d.losses).map(([key, max]) => (
+
+ Sector {splitLocation(key).sector} · {max} cyborgs lost
+
+ setRecover({ ...recover, [key]: Number(e.target.value) })
+ }
+ />
+
+ ))}
+
+ {total(sources)} suboids for {total(recover)} cyborgs
+
+ act({ type: 'decision', sources, recover })}
+ >
+ Confirm substitution
+
+ act({ type: 'decision', decline: true })}
+ >
+ Keep these casualties
+
+ >
+ );
+}
diff --git a/components/ix-technology.tsx b/components/ix-technology.tsx
new file mode 100644
index 00000000..2182405d
--- /dev/null
+++ b/components/ix-technology.tsx
@@ -0,0 +1,119 @@
+'use client';
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { HelpTip } from './help-tip';
+import { CardInspector } from './card-inspector';
+import type { Action, GameView } from '@/game/engine';
+export function IxTechnology({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (a: Action) => void;
+ busy: boolean;
+}) {
+ const kind = g.decision?.kind;
+ const me = g.players.find((p) => p.id === g.me)!;
+ const [position, setPosition] = useState('bottom');
+ const cards =
+ kind === 'ixSetup'
+ ? g.ixTechnology?.setup
+ : kind === 'ixAuction'
+ ? g.ixTechnology?.pool
+ : me.hand;
+ if (kind === 'ixAllyCard')
+ return (
+ <>
+
+ You purchased{' '}
+
+ {g.ixPurchased?.name ?? 'a card that is no longer in your hand'}
+
+ . Your Ixian alliance lets you keep it or discard it for the top card
+ of the deck. The replacement stays private.{' '}
+
+
+ {g.ixPurchased && }
+ act({ type: 'decision', accept: false })}
+ >
+ Keep purchase
+
+ act({ type: 'decision', accept: true })}
+ >
+ Request replacement
+
+ >
+ );
+ return (
+ <>
+
+ {kind === 'ixSetup'
+ ? 'Choose your starting card. The remaining cards are shuffled and dealt privately, one to each other faction; Harkonnen then draws its extra card.'
+ : kind === 'ixAuction'
+ ? 'Privately choose one card to return to the deck. The remaining pool will be shuffled for auction.'
+ : 'Exchange one card from your hand for the upcoming card, before Atreides looks. You commit your offered card before learning what you receive. This advantage can be attempted once this round.'}{' '}
+
+
+ {kind === 'ixAuction' && (
+
+ Return chosen card to
+ setPosition(e.target.value)}
+ >
+ Bottom of deck
+ Top of deck
+
+
+ )}
+
+ {cards?.map((c) => (
+
+
+ {c.name}
+
+
+
+ act({
+ type: 'decision',
+ card: c.id,
+ ...(kind === 'ixAuction' ? { position } : {}),
+ })
+ }
+ >
+ {kind === 'ixAuction'
+ ? 'Return card'
+ : kind === 'ixSetup'
+ ? 'Choose starting card'
+ : 'Offer this card'}
+
+
+ ))}
+
+ {kind === 'ixTechnology' && (
+ act({ type: 'decision', decline: true })}
+ >
+ Save technology for a later card
+
+ )}
+ >
+ );
+}
diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx
new file mode 100644
index 00000000..ddfc941a
--- /dev/null
+++ b/components/juice-of-sapho.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { useId } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { richeseCardDefinition } from '@/game/richese-cards';
+import { Button } from './ui/button';
+import { CardInspector } from './card-inspector';
+
+/** Reasons use public timing only; exact card reservation checks stay on the server. */
+function unavailableReason(game: GameView) {
+ if (game.status !== 'playing')
+ return 'Order changes are available during play.';
+ if (game.truthtrance || game.response || game.decision || game.phaseOpening)
+ return 'Finish the current interaction before changing turn order.';
+ if (game.phase === 3) {
+ if (game.richeseAuction?.method === 'silent')
+ return 'Silent bids are simultaneous. These controls do not change their tie order.';
+ if (game.richeseAuction?.method !== 'onceAround')
+ return 'These bidding controls require a Once Around auction.';
+ return 'No order change is available for you in this lot. You must still be eligible and have not bid; first is available only before bidding begins. A card already committed elsewhere cannot be used.';
+ }
+ if (game.phase === 5)
+ return 'Movement order can change only before the current combined shipment and movement turn begins. First also requires that nobody has begun a turn this phase and that Advanced Guild timing is absent. Already-held positions and committed cards are unavailable.';
+ return 'These controls support Once Around bidding and movement order. Battle aggressor and other phase or auction modes remain unfinished.';
+}
+
+export function JuiceOfSapho({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const me = game.players.find((p) => p.id === game.me);
+ const card = me?.hand?.find(
+ (c) => richeseCardDefinition(c)?.card.effect === 'juiceOfSapho',
+ );
+ if (!card) return null;
+ const options = game.saphoOptions ?? [];
+ return (
+
+
+ Juice of Sapho: change turn order
+
+
+
+ Choose one available order change, then discard Juice of Sapho. This
+ does not grant an extra bid, shipment or movement.
+
+
+ {options.length ? (
+ options.map((option) => {
+ const once = option.scope === 'onceAround';
+ return (
+
+
+ {once
+ ? `Bid ${option.mode} in the current Once Around lot. Each eligible player still has only one bidding opportunity.`
+ : `Take the ${option.mode} remaining combined shipment and movement turn. Completed turns stay completed${option.mode === 'last' ? ', and the Guild cannot move behind you' : ''}.`}
+
+
{
+ if (!busy) act({ type: 'card', card: card.id, ...option });
+ }}
+ >
+ {once ? 'Bid' : 'Take your movement turn'} {option.mode} and
+ discard Juice of Sapho
+
+
+ );
+ })
+ ) : (
+
+ {unavailableReason(game)}
+
+ )}
+
+ Battle aggressor and other phase or auction modes are still
+ unfinished.
+
+
+
+ );
+}
diff --git a/components/leader-inspector.tsx b/components/leader-inspector.tsx
new file mode 100644
index 00000000..e82d1c7f
--- /dev/null
+++ b/components/leader-inspector.tsx
@@ -0,0 +1,317 @@
+'use client';
+
+import { useId } from 'react';
+import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
+import { Eye, X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogDescription,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import { HELP } from '@/game/reference';
+import { LeaderPortrait } from './leader-portrait';
+
+/** Supply only identity information this viewer is entitled to inspect. */
+export type LeaderDisplayIdentity = Readonly<
+ { id?: string; name: string; factionName: string } & (
+ | { cheapHero: true; strength: 0 }
+ | { cheapHero?: false; strength: number | string }
+ )
+>;
+export type LeaderInspectorProps = {
+ identity: LeaderDisplayIdentity;
+ kind?: 'leader' | 'traitor' | 'faceDancer';
+};
+
+/** An original geometric medallion, not a portrait or a printed component reproduction. */
+function IdentityMedallion({ traitor }: { traitor: boolean }) {
+ const gradient = `${useId()}-seal`;
+ return (
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 16 }, (_, index) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+/** Read-only: no game, catalog lookup, deck, session or action callback is accepted. */
+export function LeaderInspector({
+ identity,
+ kind = 'leader',
+}: LeaderInspectorProps) {
+ const traitor = kind === 'traitor';
+ const faceDancer = kind === 'faceDancer';
+ const label = faceDancer ? 'Face Dancer' : kind;
+ const cheapHero = identity.cheapHero === true;
+ const description = faceDancer
+ ? cheapHero
+ ? 'This Face Dancer identity matches either Cheap Hero or Cheap Heroine used as the winning battle leader, regardless of the hero card’s name or the player’s faction.'
+ : 'This Face Dancer identity matches the named winning leader. Tleilaxu can reveal an unrevealed match after that battle and its rewards are settled.'
+ : traitor
+ ? cheapHero
+ ? 'This traitor identity matches either Cheap Hero or Cheap Heroine used as a battle leader, regardless of the hero card’s name or the player’s faction.'
+ : 'This traitor card identifies the named leader. Holding it does not add that leader to your available battle leaders.'
+ : cheapHero
+ ? 'A Cheap Hero or Heroine can stand in for a leader in a battle plan. Its leader strength is zero.'
+ : 'This identity shows the leader’s faction and strength. Battle powers can change the strength used in a battle.';
+ return (
+
+
+ }
+ aria-label={`Inspect ${label}: ${identity.name}`}
+ >
+
+ Inspect {label}
+
+
+
+
+
+
+ {label} inspection
+
+
+ }
+ >
+
+ Close
+
+
+
+
+
+
+ {identity.factionName}
+
+ }
+ />
+
+ {identity.name}
+
+
+
+ Leader strength
+
+
+ {identity.strength}
+
+
+
+ {faceDancer
+ ? 'Face Dancer identity'
+ : traitor
+ ? 'Traitor identity'
+ : 'Leader identity'}{' '}
+ · gameplay guide
+
+
+
+
+ {description}
+
+ {identity.id === 'choam-auditor' && (
+
+
+ Advanced CHOAM has this additional strength-2 leader and
+ its matching traitor identity. It remains separate from
+ the five ordinary leaders.
+
+
+ After using the Auditor in battle, CHOAM may inspect two
+ random opposing hand cards if it survived, or one if it
+ died. Exclude cards used in that battle. The opponent may
+ pay CHOAM one spice per card actually available to cancel
+ the entire inspection; partial cancellation is
+ unavailable. Karama can prevent the audit.
+
+
+ The Auditor may use CHOAM’s one normal leader revival
+ without waiting for all other leaders to enter the Tanks.
+ It cannot be captured by Harkonnen, acquired as a Tleilaxu
+ foreign ghola, or receive a leader skill. CHOAM may revive
+ it with its own Ghola card.
+
+
+ )}
+ {faceDancer ? (
+ <>
+
+ Inspection does not reveal a Face Dancer or replace
+ forces. Use the table’s Face Dancer decision after another
+ faction’s winning battle, losses, card choices and rewards
+ have been resolved.
+
+
+ Return the winner’s surviving forces to reserves. Tleilaxu
+ may replace up to that many with their own forces from
+ reserves or the board. Send the winning leader to the
+ tanks without another leader bounty; an already dead
+ leader does not die again.
+
+
+ Kwisatz Haderach does not protect the accompanying leader
+ from Face Dancing. Karama cannot cancel this reveal or the
+ replacement forces.
+
+
+ Keep a revealed Face Dancer face up until all three have
+ been used, then shuffle them back into the unused deck and
+ draw three new identities. During Mentat Pause, one
+ unrevealed Face Dancer may instead be exchanged once per
+ turn; that exchange has a Karama response.
+
+ {cheapHero && (
+
+ Either hero can be the winning identity for Face
+ Dancing. Its leader strength is zero; the hero treachery
+ card and this Face Dancer identity remain separate
+ components.
+
+ )}
+ >
+ ) : traitor ? (
+ <>
+
+ Inspection is not a traitor call and does not reveal this
+ identity to other players. After the battle plans are
+ revealed, use the table’s traitor decision to make a legal
+ call.
+
+
+ Keep the identity after revealing it; it can match again
+ in later battles. A matching identity alone does not
+ bypass battle protections such as Kwisatz Haderach.
+
+ {!cheapHero && (
+
+ Keeping your own leader as a traitor can prevent another
+ player from holding that traitor.
+
+ )}
+ {cheapHero && (
+
+ This is the expansion traitor identity, separate from
+ the hero treachery cards. It can match any Cheap Hero or
+ Heroine used as a battle leader. The hero has zero
+ leader strength and grants no leader bounty.
+
+ )}
+ >
+ ) : (
+ <>
+
+ {HELP.leader}
+
+
+ Inspection does not choose or commit a leader. Close this
+ view to review the available leaders in your battle plan.
+
+ {cheapHero && (
+
+ The hero treachery card is discarded after use. The
+ separate Cheap Hero traitor identity can match either
+ hero.
+
+ )}
+ >
+ )}
+ {identity.strength === 'X' && (
+
+ X marks variable strength. Use the faction’s battle rule to
+ determine its value against the opposing leader.
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/components/leader-portrait.tsx b/components/leader-portrait.tsx
new file mode 100644
index 00000000..4a3dcca8
--- /dev/null
+++ b/components/leader-portrait.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { useState, type ReactNode } from 'react';
+import Image from 'next/image';
+import { leaderArt } from '@/game/leader-art';
+import { specialLeaderArt } from '@/game/special-leader-art';
+
+/** Supply only an identity already authorized for this viewer. No game/deck lookup. */
+export function LeaderPortrait({
+ identityId,
+ name,
+ fallback,
+ className = 'size-20',
+ sizes = '80px',
+ badge,
+}: {
+ identityId?: string;
+ name: string;
+ fallback: ReactNode;
+ className?: string;
+ sizes?: string;
+ badge?: ReactNode;
+}) {
+ const [failedSource, setFailedSource] = useState(null);
+ const art = leaderArt(identityId, name) ?? specialLeaderArt(identityId, name);
+ if (!art || failedSource === art.src) return fallback;
+ return (
+
+
+ setFailedSource(art.src)}
+ className="h-full w-full object-cover"
+ style={{ objectPosition: art.objectPosition }}
+ />
+
+
+ {badge !== undefined && (
+
+ {badge}
+
+ )}
+
+ );
+}
diff --git a/components/mobile-stronghold.tsx b/components/mobile-stronghold.tsx
new file mode 100644
index 00000000..08164962
--- /dev/null
+++ b/components/mobile-stronghold.tsx
@@ -0,0 +1,157 @@
+'use client';
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { HelpTip } from './help-tip';
+import {
+ GRAPH,
+ TERRITORIES,
+ MOBILE_LOCATION,
+ mobileRouteDistance,
+ splitLocation,
+ territory,
+} from '@/game/board';
+import type { Action, GameView } from '@/game/engine';
+const label = (key: string) => {
+ const loc = splitLocation(key);
+ return `${territory(loc.territory).name}${loc.sector ? ` · sector ${loc.sector}` : ''}`;
+};
+export function MobileStronghold({
+ game: g,
+ act,
+ busy,
+ card,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+ card?: string;
+}) {
+ const pointer = g.mobileStronghold?.location;
+ const placement = !pointer;
+ const [destination, setDestination] = useState('polar_sink:0');
+ const [route, setRoute] = useState(pointer ? [pointer] : []);
+ const [collect, setCollect] = useState(true);
+ const max = card ? 2 : 3;
+ const current = route.at(-1) ?? '';
+ const choices = placement
+ ? TERRITORIES.filter((t) => t.type !== 'stronghold').flatMap((t) =>
+ t.sectors.map((s) => `${t.id}:${s}`),
+ )
+ : (GRAPH[current] ?? []).filter(
+ (key) =>
+ mobileRouteDistance([...route, key]) <= max &&
+ splitLocation(key).sector !== g.storm &&
+ splitLocation(route[0]).sector !== g.storm,
+ );
+ const me = g.players.find((p) => p.id === g.me)!;
+ const preview = route.reduce(
+ (sum, key) =>
+ sum + Math.min(g.spice[key] ?? 0, (me.forces[MOBILE_LOCATION] ?? 0) * 2),
+ 0,
+ );
+ const valid =
+ placement ||
+ (route.length > 1 &&
+ route.every((key) => splitLocation(key).sector !== g.storm) &&
+ territory(splitLocation(current).territory).type !== 'stronghold');
+ return (
+
+
+ {placement
+ ? 'Point your stronghold at a sector in any non-stronghold territory. Placement does not collect spice.'
+ : `Move the stronghold up to ${max} territories. Choose each sector along the route; the forces stay inside.`}{' '}
+
+
+ {!placement && pointer && splitLocation(pointer).sector === g.storm && (
+
+ The pointing sector is in storm. Keep the current position until the
+ storm clears.
+
+ )}
+ {!placement && (
+ <>
+
+ {route.map((key) => (
+
+ {label(key)}
+ {g.spice[key] ? ` · ${g.spice[key]} spice` : ''}
+
+ ))}
+
+
+ {mobileRouteDistance(route)} / {max} territories ·{' '}
+ {collect ? preview : 0} spice to collect
+
+ >
+ )}
+
+ {placement ? 'Pointing sector' : 'Next sector'}
+ {
+ if (placement) setDestination(e.target.value);
+ else if (e.target.value) setRoute([...route, e.target.value]);
+ }}
+ >
+ {!placement && Choose next sector… }
+ {choices.map((key) => (
+
+ {label(key)}
+
+ ))}
+
+
+ {!placement && (
+ <>
+
setRoute(route.slice(0, -1))}
+ >
+ Undo last step
+
+
+ setCollect(e.target.checked)}
+ />
+ Collect spice along the route
+
+ {!valid && route.length > 1 && (
+
Finish in a non-stronghold territory.
+ )}
+ >
+ )}
+
+ act(
+ placement
+ ? { type: 'decision', location: destination }
+ : card
+ ? { type: 'card', mode: 'special', card, route, collect }
+ : { type: 'decision', route, collect },
+ )
+ }
+ >
+ {placement
+ ? 'Place stronghold'
+ : card
+ ? 'Spend Karama and relocate'
+ : 'Declare stronghold route'}
+
+ {!placement && !card && (
+
act({ type: 'decision', decline: true })}
+ >
+ Keep current position
+
+ )}
+
+ );
+}
diff --git a/components/moritani-retention.tsx b/components/moritani-retention.tsx
new file mode 100644
index 00000000..fb1d427e
--- /dev/null
+++ b/components/moritani-retention.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { territory } from '@/game/board';
+import { battleCardLabel } from '@/game/battle-cards';
+import { Button } from './ui/button';
+import { CardInspector, CardRules } from './card-inspector';
+
+export function MoritaniRetention({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const groupId = useId();
+ const [selected, setSelected] = useState(null);
+ const decision = game.decision;
+ if (decision?.kind !== 'moritaniRetention' || decision.player !== game.me)
+ return null;
+ const me = game.players.find((player) => player.id === game.me);
+ if (!me) return null;
+ const cards = (me.hand ?? []).filter((card) =>
+ decision.cards.includes(card.id),
+ );
+ const selectedCard = cards.find((card) => card.id === selected);
+ const owner = game.players.find((player) => player.id === decision.owner);
+ const disabled = busy || !!game.response || !!game.truthtrance;
+ return (
+
+
+ You lost the battle in {territory(decision.territory).name}. Your
+ Moritani ally{owner ? `, ${owner.name},` : ''} lets you choose one
+ eligible card played in that battle to keep. Using this ability is
+ optional.
+
+
+ Declare one card, then the table may respond with Karama before you
+ retain it. If the power is canceled, all these cards are discarded.
+ Declining also discards them.
+
+
+
+ Choose one card to retain
+
+ {cards.map((card) => (
+
+
+ setSelected(card.id)}
+ className="size-5 shrink-0 accent-[#d7b673]"
+ />
+ {card.name}
+
+ {battleCardLabel(card.kind)}
+
+
+
+ ))}
+
+ {!cards.length && (
+
+ No eligible card remains in your hand. Decline to continue.
+
+ )}
+
+ selectedCard && act({ type: 'decision', keep: selectedCard.id })
+ }
+ >
+ Declare selected card
+
+ act({ type: 'decision', keep: null })}
+ >
+ Decline retention
+
+
+ );
+}
diff --git a/components/moritani-terror.tsx b/components/moritani-terror.tsx
new file mode 100644
index 00000000..5ad80f17
--- /dev/null
+++ b/components/moritani-terror.tsx
@@ -0,0 +1,710 @@
+'use client';
+
+import { useId, useState } from 'react';
+import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
+import { Eye, X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogDescription,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import type { Action, GameView } from '@/game/engine';
+import { territory } from '@/game/board';
+import {
+ TERROR_COMMON_GAMEPLAY,
+ TERROR_DEFINITIONS,
+ TERROR_STRONGHOLDS,
+ type TerrorKind,
+ type TerrorToken,
+} from '@/game/moritani-terror';
+
+type ProjectedToken = NonNullable['tokens'][number];
+
+/** Original vector marks. A hidden token always uses the same face and geometry. */
+function TerrorArt({
+ kind,
+ large = false,
+}: {
+ kind?: TerrorKind;
+ large?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {kind === 'assassination' ? (
+ <>
+
+
+ >
+ ) : kind === 'atomics' ? (
+ <>
+
+
+
+ >
+ ) : kind === 'extortion' ? (
+ <>
+
+
+ >
+ ) : kind === 'robbery' ? (
+ <>
+
+
+ >
+ ) : kind === 'sabotage' ? (
+ <>
+
+
+ >
+ ) : kind === 'sneakAttack' ? (
+ <>
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+function tokenContext(token: ProjectedToken) {
+ if (token.status === 'placed' && token.location)
+ return `Placed · ${territory(token.location).name}`;
+ if (token.status === 'extortion') return 'Revealed · awaiting Mentat';
+ if (token.status === 'removed') return 'Revealed · removed from play';
+ return 'Private supply';
+}
+
+function TokenInspector({
+ token,
+ owner,
+}: {
+ token: ProjectedToken;
+ owner: boolean;
+}) {
+ const revealed = token.status === 'removed' || token.status === 'extortion';
+ // Defense in depth: even an accidentally over-broad projection cannot render
+ // an opponent's hidden face. Never derive a secret kind from a physical ID.
+ const kind = (owner || revealed) && 'kind' in token ? token.kind : undefined;
+ const definition = kind ? TERROR_DEFINITIONS[kind] : undefined;
+ const title = definition?.name ?? 'Hidden Terror';
+ const context = tokenContext(token);
+ return (
+
+
+ }
+ aria-label={`Inspect ${title}: ${context}`}
+ >
+
+
+ {title}
+
+
+ {context}
+
+
+ Inspect
+
+
+
+
+
+
+
+ Moritani · Terror
+
+
+ }
+ >
+ Close
+
+
+
+
+
+
+ {context}
+
+
+
+ {title}
+
+
+ {kind
+ ? 'Original token illustration · gameplay guide'
+ : 'This placed token remains face down. Its identity is visible only to Moritani.'}
+
+
+
+
+ {definition?.summary ??
+ 'A hidden Terror token marks this stronghold. Inspecting it does not reveal its identity or trigger an effect.'}
+
+ {(definition?.gameplay ?? TERROR_COMMON_GAMEPLAY).map(
+ (paragraph) => (
+
+ {paragraph}
+
+ ),
+ )}
+
+ Robbery, Sabotage, Sneak Attack and ordinary native-leader
+ Assassination have controls for supported entry reactions.
+ Enemy of My Enemy has partial alliance controls. Atomics and
+ Extortion remain under implementation, and complete Moritani
+ games remain unavailable. Inspection explains the rules; it
+ does not reveal a token or activate an effect.
+
+
+
+
+
+
+
+ );
+}
+
+export function MoritaniTerrorSupply({ game }: { game: GameView }) {
+ const titleId = useId();
+ const view = game.moritaniTerror;
+ if (!view) return null;
+ const owner =
+ game.players.find((p) => p.id === game.me)?.faction === 'moritani';
+ const tokens = view.tokens.filter(
+ (token) => owner || token.status !== 'available',
+ );
+ return (
+
+
+ Moritani Terror
+
+
+ {owner
+ ? 'Your supply and placed tokens. Inspect a face without revealing it to the table.'
+ : 'Placed tokens stay hidden. Revealed tokens can be inspected by everyone.'}
+
+ {tokens.length ? (
+
+ {tokens.map((token) => (
+
+ ))}
+
+ ) : (
+ No public Terror tokens.
+ )}
+
+ );
+}
+
+export function MoritaniPlacement({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const formId = useId();
+ const [selectedToken, setSelectedToken] = useState('');
+ const [selectedTerritory, setSelectedTerritory] = useState('');
+ const decision = game.decision;
+ if (
+ decision?.kind !== 'moritaniPlacement' ||
+ decision.player !== game.me ||
+ game.players.find((p) => p.id === game.me)?.faction !== 'moritani' ||
+ !game.moritaniTerror
+ )
+ return null;
+ const tokens = game.moritaniTerror.tokens.filter(
+ (token): token is TerrorToken =>
+ 'kind' in token &&
+ (token.status === 'available' || token.status === 'placed'),
+ );
+ const token =
+ tokens.find((candidate) => candidate.id === selectedToken) ?? tokens[0];
+ const destinations = TERROR_STRONGHOLDS.filter(
+ (to) =>
+ to !== token?.location &&
+ !game.moritaniTerror!.tokens.some(
+ (candidate) =>
+ candidate.status === 'placed' && candidate.location === to,
+ ),
+ );
+ const destination = destinations.includes(selectedTerritory)
+ ? selectedTerritory
+ : destinations[0];
+ const waiting = Boolean(game.response);
+ const disabled = busy || waiting;
+ return (
+
+
+
+ Choose one token and a stronghold without a Terror token for this Mentat
+ Pause. Storm does not prevent placement. Choose privately; the token’s
+ face stays hidden from opponents.
+
+
+
+ Your Terror token
+
+ setSelectedToken(event.target.value)}
+ >
+ {!tokens.length && No available token }
+ {tokens.map((candidate) => (
+
+ {TERROR_DEFINITIONS[candidate.kind].name} ·{' '}
+ {candidate.location
+ ? territory(candidate.location).name
+ : 'Supply'}
+
+ ))}
+
+
+ Destination stronghold
+
+ setSelectedTerritory(event.target.value)}
+ >
+ {!destinations.length && (
+ No empty stronghold
+ )}
+ {destinations.map((to) => (
+
+ {territory(to).name}
+
+ ))}
+
+
+ {waiting
+ ? 'Waiting for the table’s Karama response. The token has not moved.'
+ : 'The table may use Karama before your placement or relocation takes effect.'}
+
+ {
+ if (token && destination)
+ act({
+ type: 'decision',
+ token: token.id,
+ territory: destination,
+ });
+ }}
+ >
+ {token?.status === 'placed'
+ ? 'Declare relocation'
+ : 'Declare placement'}
+
+ act({ type: 'decision', decline: true })}
+ >
+ Pass this Mentat opportunity
+
+
+
+ );
+}
+
+export function MoritaniEntry({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const formId = useId();
+ const [selectedCard, setSelectedCard] = useState('');
+ const [selectedAmount, setSelectedAmount] = useState(0);
+ const entry = game.terrorEntry;
+ const me = game.players.find((player) => player.id === game.me);
+ if (
+ game.decision?.kind !== 'moritaniTerror' ||
+ game.decision.player !== game.me ||
+ !entry ||
+ !me
+ )
+ return null;
+
+ const disabled = busy || Boolean(game.response) || Boolean(game.truthtrance);
+ const controlClass =
+ 'h-auto min-h-11 whitespace-normal motion-reduce:transition-none';
+
+ // This entrant branch must precede every private kind, definition and token lookup.
+ if (entry.stage === 'allianceReply') {
+ if (entry.entrant !== game.me) return null;
+ const moritani = game.players.find(
+ (player) => player.faction === 'moritani',
+ );
+ if (!moritani) return null;
+ return (
+
+
+
+ {moritani.name} offers you an alliance after your arrival in{' '}
+ {territory(entry.territory).name}. The Terror token remains hidden.
+
+
+ Accepting forms your alliance with Moritani, ends any existing
+ alliances either of you has, and returns the token to Moritani’s
+ supply without revealing it. Refusing requires the token to be
+ revealed and its effect to resolve.
+
+ {[me, moritani]
+ .filter((player) => player.ally)
+ .map((player) => {
+ const formerPartner = game.players.find(
+ (partner) => partner.id === player.ally,
+ );
+ return (
+
+ {player.name}’s alliance with{' '}
+ {formerPartner?.name ?? 'their current ally'} will end if you
+ accept.
+
+ );
+ })}
+
+ act({ type: 'decision', accept: true })}
+ >
+ Accept alliance
+
+ act({ type: 'decision', accept: false })}
+ >
+ Refuse alliance
+
+
+
+ );
+ }
+
+ if (me.faction !== 'moritani' || !('kind' in entry) || !entry.kind)
+ return null;
+
+ const definition = TERROR_DEFINITIONS[entry.kind];
+ const token = game.moritaniTerror?.tokens.find(
+ (candidate) => 'kind' in candidate && candidate.kind === entry.kind,
+ );
+ const entrant = game.players.find((player) => player.id === entry.entrant);
+ const hand = me.hand ?? [];
+ const selected = hand.find((card) => card.id === selectedCard);
+ const discardCard = selected ?? hand[0];
+ const giving = entry.stage === 'gift';
+ const sneak = entry.sneakAttack;
+ const maximum = sneak?.maximum ?? 0;
+ const amount = selectedAmount <= maximum ? selectedAmount : 0;
+ const arrival = {
+ shipment: 'by shipment',
+ movement: 'by movement',
+ guildTransport: 'by Guild transport',
+ advisor: 'as an accompanying advisor',
+ wormRide: 'by worm ride',
+ ambassador: 'through the Fremen Ambassador',
+ }[entry.cause];
+
+ return (
+
+
+
+ {entrant?.name ?? 'Another faction'} entered{' '}
+ {territory(entry.territory).name}, sector {entry.sector}, {arrival}.
+
+ {token && (
+
+
+
+ )}
+
+ {entry.stage === 'offer' && (
+ <>
+
+ You may reveal this token against the entrant, or leave it hidden
+ and continue their action. Inspection keeps its face private.
+
+ {entry.kind === 'assassination' && entry.canReveal && (
+
+ Revealing Assassination immediately selects a random eligible
+ native leader. You do not choose the victim.
+
+ )}
+ {entry.kind === 'sneakAttack' && sneak && (
+
+
+ Up to {maximum} of your reserve forces are available.
+ {sneak.blocked
+ ? ` Positive entry is blocked: ${sneak.blocked}`
+ : ' They may enter this territory and sector for free.'}
+
+
+ You may leave this token hidden, or reveal it and choose how
+ many forces to send. Revealing spends the token even if you
+ send no forces; zero remains available when entry is blocked.
+
+
+ )}
+ {entry.canOfferAlliance ? (
+
+
+ Enemy of My Enemy lets you offer an alliance before revealing
+ this token. Acceptance ends both existing alliances and
+ returns the token hidden. Refusal requires its effect to
+ resolve. The table’s Karama response comes before the entrant
+ decides.
+
+
act({ type: 'decision', alliance: true })}
+ >
+ Offer alliance
+
+
+ ) : entry.allianceBlockedReason ? (
+
+ Enemy of My Enemy: {entry.allianceBlockedReason}
+
+ ) : null}
+ {entry.canReveal ? (
+
act({ type: 'decision', reveal: true })}
+ >
+ Reveal {definition.name}
+
+ ) : (
+
+ {entry.revealBlocked ??
+ `${definition.name} is still under implementation.`}{' '}
+ Leave the token hidden to continue this arrival.
+
+ )}
+
act({ type: 'decision', decline: true })}
+ >
+ Leave token hidden
+
+
+ Robbery, Sabotage, Sneak Attack and ordinary native-leader
+ Assassination support eligible arrivals. Enemy of My Enemy has
+ partial alliance controls. Atomics and Extortion are not yet
+ available; complete Moritani games remain unavailable.
+
+ >
+ )}
+ {entry.stage === 'robbery' && (
+ <>
+
+ Take half the entrant’s spice, rounded up, or draw the top
+ Treachery Card. A draw above your hand limit requires you to
+ choose a card to discard afterward.
+
+
act({ type: 'decision', choice: 'spice' })}
+ >
+ Take half their spice
+
+
act({ type: 'decision', choice: 'card' })}
+ >
+ Draw a Treachery Card
+
+ >
+ )}
+ {entry.stage === 'sneakAttack' && (
+ <>
+
+ Send forces from your reserves into{' '}
+ {territory(entry.territory).name}, sector {entry.sector}, for
+ free. This entry does not use your ordinary shipment or movement
+ turn. You may send no forces.
+
+
+ Forces from your reserves
+
+
+ setSelectedAmount(Number(event.target.value))
+ }
+ >
+ {Array.from({ length: maximum + 1 }, (_, count) => (
+
+ {count}
+ {count === 0 ? ' · no forces' : ''}
+
+ ))}
+
+
+ {sneak?.blocked ??
+ `Up to ${maximum} forces are available for this entry.`}
+
+
{
+ if (sneak && amount > 0 && !sneak.blocked)
+ act({ type: 'decision', amount });
+ }}
+ >
+ Send selected forces
+
+
act({ type: 'decision', amount: 0 })}
+ >
+ Send no forces
+
+ >
+ )}
+ {(entry.stage === 'discard' || giving) && (
+ <>
+
+ {giving
+ ? 'Sabotage’s random discard has resolved where possible. You may now give the entrant one of your own cards, or keep your hand.'
+ : 'Your Robbery draw exceeded your hand limit. Choose one of your own cards to discard, including the card just drawn.'}
+
+
+ {giving ? 'Card to give (optional)' : 'Your card to discard'}
+
+
setSelectedCard(event.target.value)}
+ >
+ {giving && None · keep my hand }
+ {!giving && !hand.length && (
+ No available card
+ )}
+ {hand.map((card) => (
+
+ {card.name}
+
+ ))}
+
+
{
+ if (giving && !selected)
+ act({ type: 'decision', decline: true });
+ else {
+ const card = giving ? selected : discardCard;
+ if (card) act({ type: 'decision', card: card.id });
+ }
+ }}
+ >
+ {giving
+ ? selected
+ ? 'Give selected card'
+ : 'Keep my hand'
+ : 'Discard selected card'}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/nullentropy-box.tsx b/components/nullentropy-box.tsx
new file mode 100644
index 00000000..4e304590
--- /dev/null
+++ b/components/nullentropy-box.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { useId } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { Button } from './ui/button';
+import { CardInspector, CardRules } from './card-inspector';
+
+type BoxProps = {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+};
+
+/** Before payment this renders only the owned Box, never discard candidates. */
+export function NullentropyBox({ game, act, busy }: BoxProps) {
+ const id = useId();
+ const box = game.nullentropy;
+ if (!box) return null;
+ if (box.search)
+ return (
+
+ Your Nullentropy Box search is paid. Complete the private card choice in
+ the decision panel above.
+
+ );
+ return (
+
+
+ Nullentropy Box: paid discard search
+
+
+
+ Pay 2 spice to the bank to privately search the Treachery discard
+ pile. Choose one card other than any Nullentropy Box. The remaining
+ pile is shuffled, then your played Box is discarded on top.
+
+
+ Payment begins the search. There is no free cancellation after seeing
+ the cards. If only one card is eligible, the server takes it
+ automatically.
+
+
+
+ {box.blocked && (
+
+ {box.blocked}
+
+ )}
+
{
+ if (!busy && !box.blocked) act({ type: 'card', card: box.card.id });
+ }}
+ >
+ Pay 2 spice and search privately
+
+
+
+ );
+}
+
+/** Only the paid, entitled projection supplies these temporary discard faces. */
+export function NullentropySearch({ game, act, busy }: BoxProps) {
+ const search = game.nullentropy?.search;
+ if (
+ game.decision?.kind !== 'nullentropy' ||
+ game.decision.player !== game.me ||
+ !search
+ )
+ return null;
+ return (
+
+
+ You have paid 2 spice. Choose one card for your hand. This finishes the
+ search and discards your Box on top of the shuffled remainder.
+
+
+ These discard cards are visible only to you during this paid search.
+ Inspection does not select a card.
+
+
+ {search.cards.map((card) => (
+
+ {card.name}
+
+
+ {
+ if (!busy)
+ act({ type: 'decision', event: search.event, card: card.id });
+ }}
+ >
+ Take {card.name} and finish search
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/ornithopter-movement.tsx b/components/ornithopter-movement.tsx
new file mode 100644
index 00000000..42757472
--- /dev/null
+++ b/components/ornithopter-movement.tsx
@@ -0,0 +1,226 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import {
+ validateCohortSelection,
+ type OrnithopterMode,
+} from '@/game/ornithopter';
+import { location, splitLocation, gameTerritories } from '@/game/board';
+import { isAdvisor } from '@/game/advisors';
+import { botGroundMoveAllowed } from '@/game/bot-mobility';
+import { Button } from './ui/button';
+import { CardInspector, CardRules } from './card-inspector';
+
+/** The parent supplies its existing ordinary/elite/multi-sector/marker draft. */
+export function OrnithopterMovement({
+ game,
+ act,
+ busy,
+ move,
+ unavailableReason,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+ move: Action | null;
+ unavailableReason?: string;
+}) {
+ const id = useId();
+ const [selected, setSelected] = useState('range3');
+ const card = game.ornithopter;
+ const me = game.players.find((p) => p.id === game.me);
+ if (!card || !me) return null;
+ const active = card.active;
+ const mode =
+ active?.mode ??
+ (
+ card.modes.find(
+ (option) => option.mode === selected && !option.blocked,
+ ) ??
+ card.modes.find((option) => !option.blocked) ??
+ card.modes[0]
+ )?.mode;
+ let reason =
+ unavailableReason ??
+ card.blocked ??
+ (!active
+ ? card.modes.find((option) => option.mode === mode)?.blocked
+ : null) ??
+ (!move || move.type !== 'move'
+ ? 'Select a valid movement group and destination above.'
+ : null);
+ if (!reason && mode === 'twoGroups' && me.fremenMovementBlocked && move) {
+ const selectedForces = typeof move.from === 'string'
+ ? { [move.from]: Number(move.amount) }
+ : (move.forces as Record | undefined) ?? {};
+ if (Object.entries(selectedForces).some(([from, count]) => count > 0 &&
+ !botGroundMoveAllowed(game, me, from,
+ location(String(move.territory), Number(move.sector)), 0)))
+ reason = 'Karama removed this group’s two-territory advantage. Choose a reachable destination within the remaining normal range.';
+ }
+ const physicalSource =
+ typeof move?.from === 'string'
+ ? move.from
+ : move?.forces && typeof move.forces === 'object'
+ ? Object.keys(move.forces)[0]
+ : undefined;
+ if (
+ !reason &&
+ physicalSource &&
+ isAdvisor(me, splitLocation(physicalSource).territory)
+ )
+ reason = 'Advanced advisor use of the Ornithopter card awaits its ruling.';
+ if (!reason && active?.cohort && move) {
+ const forces =
+ (move.forces as Record | undefined) ??
+ (typeof move.from === 'string'
+ ? { [move.from]: Number(move.amount) }
+ : {});
+ const elites =
+ (move.eliteForces as Record | undefined) ??
+ (typeof move.from === 'string'
+ ? { [move.from]: Number(move.elite ?? 0) }
+ : {});
+ const marker =
+ game.richeseNoField?.owner === game.me
+ ? game.richeseNoField.private?.deployed
+ : null;
+ try {
+ validateCohortSelection(
+ active.cohort,
+ me.forces,
+ me.elites?.forces ?? {},
+ forces,
+ elites,
+ typeof move.noField === 'string' && marker
+ ? {
+ tokenId: move.noField,
+ event: typeof move.event === 'string' ? move.event : '',
+ from: location(marker.location.territory, marker.location.sector),
+ }
+ : undefined,
+ );
+ } catch (error) {
+ reason =
+ error instanceof Error && /cohort|original unmoved|marker/i.test(error.message)
+ ? 'Choose forces that have not moved with this card. The first group cannot move again.'
+ : 'The selected force counts are no longer available. Choose an available group.';
+ }
+ }
+ return (
+
+
+ {active
+ ? 'Ornithopter movement in progress'
+ : 'Use Ornithopter for this movement'}
+
+
+
+ {active ? (
+ <>
+
+ {active.completed} group moved · {active.remaining} remaining.{' '}
+ {active.mode === 'twoGroups'
+ ? 'The next group must use different original forces.'
+ : 'This group has a maximum range of three territories.'}
+
+ {!!active.cohort && (
+
+
Original unmoved forces available for selection:
+
+ {Object.entries(active.cohort.forces)
+ .filter(([, n]) => n > 0)
+ .map(([key, n]) => {
+ const source = splitLocation(key);
+ return (
+
+ {gameTerritories(game).find(
+ (t) => t.id === source.territory,
+ )?.name ?? source.territory}
+ , sector {source.sector}: {n} forces{me.elites ? `, including ${active.cohort!.elites[key] ?? 0} elite` : ''}.
+
+ );
+ })}
+ {active.cohort.noField && (
+
+ One original unmoved concealed No-Field; select its current
+ source above.
+
+ )}
+
+
+ )}
+ >
+ ) : (
+ <>
+ Card movement mode
+ setSelected(e.target.value as OrnithopterMode)}
+ >
+ {card.modes.map((option) => (
+
+ {option.mode === 'range3'
+ ? 'One group, up to three territories'
+ : 'Two different groups, normal movement'}
+
+ ))}
+
+ {card.modes
+ .filter((option) => option.blocked)
+ .map((option) => (
+
+ {option.mode === 'range3'
+ ? 'Three-territory mode'
+ : 'Two-group mode'}
+ : {option.blocked}
+
+ ))}
+
+ Uses the forces, elite mix, No-Field and destination selected above.
+ The card is played with the first move and discarded when its use
+ ends. Storm and entry rules still apply.
+
+ >
+ )}
+ {reason && (
+
+ {reason}
+
+ )}
+ {
+ if (busy || reason || !move || !mode) return;
+ const action = { ...move };
+ delete action.movementCard;
+ delete action.ornithopter;
+ delete action.ornithopterEvent;
+ act({
+ ...action,
+ ...(active
+ ? { ornithopterEvent: active.event }
+ : { movementCard: card.card.id, ornithopter: mode }),
+ });
+ }}
+ >
+ {active
+ ? 'Move next selected group'
+ : 'Play Ornithopter and move selected group'}
+
+
+ );
+}
diff --git a/components/portable-snooper.tsx b/components/portable-snooper.tsx
new file mode 100644
index 00000000..b83aea19
--- /dev/null
+++ b/components/portable-snooper.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import { useId } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { Button } from './ui/button';
+import { CardInspector, CardRules } from './card-inspector';
+
+export function PortableSnooper({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const snooper = game.portableSnooper;
+ if (!snooper) return null;
+ const reason =
+ snooper.blocked ??
+ (!snooper.event ? 'A revealed battle is required for late defense.' : null);
+ return (
+
+
+ Portable Snooper: poison defense
+
+
+
+ Choose this card as your normal battle defense, or play it after plans
+ are revealed when you have room for a late defense. Finish any current
+ interaction and play before submitting your traitor decision. It
+ protects against ordinary poison, but does not stop Poison Tooth.
+
+
+
+ {reason && (
+
+ {reason}
+
+ )}
+
{
+ if (!busy && !reason && snooper.event)
+ act({
+ type: 'portableSnooper',
+ card: snooper.card.id,
+ event: snooper.event,
+ });
+ }}
+ >
+ Play Portable Snooper as a late defense
+
+
+
+ );
+}
diff --git a/components/private-battle-plan.tsx b/components/private-battle-plan.tsx
new file mode 100644
index 00000000..83c54214
--- /dev/null
+++ b/components/private-battle-plan.tsx
@@ -0,0 +1,82 @@
+'use client';
+import type { GameView } from '@/game/engine';
+import { HelpTip } from './help-tip';
+import { faction } from '@/game/catalog';
+import { leaderStrengthLabel } from '@/game/cards';
+import { BattleComponentInspection } from './battle-component-inspection';
+
+export function PrivateBattlePlan({ game }: { game: GameView }) {
+ const insight = game.battle?.fullPlanInsight;
+ if (
+ !insight ||
+ game.battle?.revealed ||
+ game.battle?.fullPlan?.owner !== game.me
+ )
+ return null;
+ const leader = game.allLeaders.find((l) => l.id === insight.plan.leader);
+ const name = (id: string | null) =>
+ id
+ ? (insight.cards.find((c) => c.id === id)?.name ??
+ game.allLeaders.find((l) => l.id === id)?.name ??
+ id)
+ : 'None';
+ return (
+
+ Atreides · private inspection
+
+ {game.players.find((p) => p.id === insight.target)?.name}’s committed
+ plan
+
+
+ Only you receive this inspection, including when you are helping an
+ ally. It stays available until both plans are publicly revealed. The
+ other combatant can prepare and seal their plan without an inspection
+ confirmation.
+
+
+
+
Dial
+ {insight.plan.dial}
+
+
+
Spice support
+ {insight.plan.support}
+
+
+
Leader
+ {name(insight.plan.leader)}
+
+
+
Weapon
+ {name(insight.plan.weapon)}
+
+
+
Defense
+ {name(insight.plan.defense)}
+
+
+
Kwisatz Haderach
+ {insight.plan.kwisatz ? 'Included' : 'Not included'}
+
+
+ p.id === insight.target)?.name ?? 'Player'
+ }
+ cards={insight.cards}
+ leader={
+ leader
+ ? {
+ name: leader.name,
+ factionName: faction(leader.faction).name,
+ strength: leaderStrengthLabel(leader),
+ }
+ : undefined
+ }
+ />
+
+ );
+}
diff --git a/components/residual-poison.tsx b/components/residual-poison.tsx
new file mode 100644
index 00000000..e3416a6e
--- /dev/null
+++ b/components/residual-poison.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import { useId } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { Button } from './ui/button';
+import { CardInspector, CardRules } from './card-inspector';
+
+type PreparationProps = {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+};
+
+export function ResidualPoison({ game, act, busy }: PreparationProps) {
+ const id = useId();
+ const poison = game.residualPoison;
+ if (!poison) return null;
+ const target = game.players.find((player) => player.id === poison.target);
+ const reason =
+ poison.blocked ??
+ (!target || !poison.event
+ ? 'Use this card against your opponent before battle leaders are chosen.'
+ : null);
+ return (
+
+
+ Residual Poison: before choosing leaders
+
+
+
+ Play against your actual battle opponent. One available opposing
+ leader is randomly sent to the Tanks, with no spice bounty, then
+ Residual Poison is discarded. You do not choose the leader.
+
+
+
+ {reason && (
+
+ {reason}
+
+ )}
+
{
+ if (!busy && !reason && poison.target && poison.event)
+ act({
+ type: 'card',
+ card: poison.card.id,
+ target: poison.target,
+ event: poison.event,
+ });
+ }}
+ >
+ Play Residual Poison against {target?.name ?? 'your battle opponent'}
+
+
+
+ );
+}
+
+/** Neutral public timing controls must never depend on either player's hand. */
+export function BattleLeaderOpportunity({ game, act, busy }: PreparationProps) {
+ const id = useId(),
+ battle = game.battle,
+ stage = battle?.preLeader;
+ if (!battle || !stage || stage.closed) return null;
+ const combatants = [battle.attacker, battle.defender];
+ const mine = combatants.includes(game.me),
+ ready = stage.ready.includes(game.me);
+ const interrupted = !!(
+ game.truthtrance ||
+ game.response ||
+ game.decision ||
+ game.phaseOpening
+ );
+ return (
+
+ Before choosing battle leaders
+
+ Both combatants have an opportunity to use available preparations before
+ either commits a leader. Continue when you are ready to choose leaders.
+
+
+ {combatants.map((player) => (
+
+ {game.players.find((p) => p.id === player)?.name ?? 'Combatant'}:{' '}
+ {stage.ready.includes(player)
+ ? 'Ready to choose leaders'
+ : 'Preparing'}
+
+ ))}
+
+ {mine &&
+ (!ready ? (
+ <>
+ {interrupted && (
+
+ Resolve the current interaction before continuing.
+
+ )}
+ {
+ if (!busy && !interrupted)
+ act({ type: 'battlePreparationReady', event: stage.event });
+ }}
+ >
+ Ready to choose leaders
+
+ >
+ ) : (
+
+ You are ready. Waiting for the other combatant.
+
+ ))}
+ {!mine && (
+
+ Waiting for the combatants to finish preparation.
+
+ )}
+
+ );
+}
diff --git a/components/revealed-battle.tsx b/components/revealed-battle.tsx
new file mode 100644
index 00000000..c8d4ab1e
--- /dev/null
+++ b/components/revealed-battle.tsx
@@ -0,0 +1,115 @@
+import type { GameView } from '@/game/engine';
+import { faction } from '@/game/catalog';
+import { leaderStrengthLabel } from '@/game/cards';
+import { isStoneBurner } from '@/game/battle-cards';
+import { stoneBurnerComparison } from '@/game/stone-burner';
+import { BattleComponentInspection } from './battle-component-inspection';
+export function RevealedBattle({ game }: { game: GameView }) {
+ const battle = game.battle;
+ if (!battle?.revealed) return null;
+ const attackerPlan = battle.plans[battle.attacker];
+ const defenderPlan = battle.plans[battle.defender];
+ const comparisons =
+ battle.cards.some(isStoneBurner) &&
+ battle.ownForces &&
+ attackerPlan &&
+ defenderPlan
+ ? (battle.stoneBurnerContext?.opponentPools ?? []).map((opponent) =>
+ stoneBurnerComparison(
+ game.me === battle.attacker ? battle.ownForces! : opponent,
+ attackerPlan.dial,
+ attackerPlan.support,
+ game.me === battle.defender ? battle.ownForces! : opponent,
+ defenderPlan.dial,
+ defenderPlan.support,
+ ),
+ )
+ : [];
+ const name = (id: string | null) =>
+ id
+ ? (game.allLeaders.find((l) => l.id === id)?.name ??
+ battle.cards.find((c) => c.id === id)?.name ??
+ id)
+ : 'None';
+ return (
+
+ {Object.entries(battle.plans).map(([id, plan]) => {
+ const leader = game.allLeaders.find((l) => l.id === plan.leader);
+ const undialed = [
+ ...new Set(
+ comparisons.flatMap(
+ (comparison) =>
+ comparison[id === battle.attacker ? 'attacker' : 'defender'],
+ ),
+ ),
+ ].sort((a, b) => a - b);
+ return (
+
+ {game.players.find((p) => p.id === id)?.name}
+
+ Dial {plan.dial}
+ {game.advanced ? ` · ${plan.support} spice support` : ''}
+ {!!plan.allyPayment && ` (${plan.allyPayment} from CHOAM)`}
+
+ Leader: {name(plan.leader)}
+ {plan.kwisatz && (
+
+ Kwisatz Haderach · +2 if leader survives · protected from
+ traitors
+
+ )}
+ Weapon: {name(plan.weapon)}
+ {undialed.length > 0 && (
+
+ Stone Burner undialed tokens: {undialed.join(' or ')}
+ {undialed.length > 1
+ ? ' (possible counts before casualty choice)'
+ : ''}
+
+ )}
+ {battle.stoneBurner[id] && (
+
+ Stone Burner:{' '}
+ {battle.stoneBurner[id] === 'kill'
+ ? 'both leaders die'
+ : 'surviving leader strength ignored'}{' '}
+ · compare undialed force tokens
+
+ )}
+ {battle.poisonTooth[id] !== undefined && (
+
+ Poison Tooth: {battle.poisonTooth[id] ? 'activated' : 'unused'}
+
+ )}
+ Defense in original plan: {name(plan.defense)}
+ {battle.lateDefense[id] && (
+ Added after reveal: {name(battle.lateDefense[id])}
+ )}
+ p.id === id)?.name ?? 'Player'
+ }
+ cards={battle.cards.filter((card) =>
+ [
+ plan.leader,
+ plan.weapon,
+ plan.defense,
+ battle.lateDefense[id],
+ ].includes(card.id),
+ )}
+ leader={
+ leader
+ ? {
+ name: leader.name,
+ factionName: faction(leader.faction).name,
+ strength: leaderStrengthLabel(leader),
+ }
+ : undefined
+ }
+ />
+
+ );
+ })}
+
+ );
+}
diff --git a/components/revival-commerce.tsx b/components/revival-commerce.tsx
new file mode 100644
index 00000000..4f826c28
--- /dev/null
+++ b/components/revival-commerce.tsx
@@ -0,0 +1,243 @@
+'use client';
+import { controlsLeader, nativeAvailable } from '@/game/leader-control';
+import { useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+export function RevivalCommerce({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const me = g.players.find((p) => p.id === g.me)!;
+ const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu');
+ const [selection, setSelection] = useState('');
+ const [prices, setPrices] = useState>({});
+ if (!tleilaxu) return null;
+ const name = (id: string) =>
+ id === 'kwisatz'
+ ? 'Kwisatz Haderach'
+ : (g.allLeaders.find((l) => l.id === id)?.name ?? id);
+ if (me.id === tleilaxu.id)
+ return (
+
+
Revival commerce
+
+ Allow another faction to revive five forces. Your ally pays for their
+ own revivals; you may offer half price.
+
+ {g.advanced && (
+
+
Foreign gholas
+
+ Fill your active leader pool up to five. A ghola keeps its traitor
+ identity and can be sold back only after it dies.
+
+ {g.players
+ .filter((p) => p.id !== me.id)
+ .flatMap((p) => p.leaders)
+ .filter(
+ (l) =>
+ l.faction !== me.faction &&
+ l.dead &&
+ !l.capturedBy &&
+ l.name !== 'Auditor',
+ )
+ .map((l) => {
+ const cost = g.revival.discount
+ ? Math.ceil(l.strength / 2)
+ : l.strength;
+ return (
+
(me.spice ?? 0) ||
+ me.gholaBlocked?.includes(l.id) ||
+ me.leaders.filter(
+ (leader) => !leader.dead && controlsLeader(me, leader),
+ ).length >= 5
+ }
+ onClick={() =>
+ act({ type: 'reviveForeignGhola', leader: l.id })
+ }
+ >
+ Revive {l.name} as a ghola · {cost} spice
+
+ );
+ })}
+
+ )}
+ {g.players
+ .filter((p) => p.id !== me.id)
+ .map((p) => (
+
+
+ {p.name} · {p.tanks} forces in the tanks{' '}
+
+
+ act({ type: 'tleilaxuRevivalLimit', target: p.id })
+ }
+ >
+ Allow five revivals
+
+
+ ))}
+ {me.ally && (
+
act({ type: 'tleilaxuAllyDiscount' })}
+ >
+ Offer ally half-price revival
+
+ )}
+ {Object.entries(g.revivalRequests).map(([id, request]) => (
+
+
+ {g.players.find((p) => p.id === id)?.name} requests{' '}
+ {name(request.leader)}.
+
+
+ Price in spice
+
+ setPrices({ ...prices, [id]: Number(e.target.value) })
+ }
+ />
+
+
+ act({
+ type: 'quoteLeaderRevival',
+ target: id,
+ amount: prices[id] ?? 0,
+ })
+ }
+ >
+ Quote this price
+
+
act({ type: 'declineLeaderRevival', target: id })}
+ >
+ Decline request
+
+
+ {request.declined
+ ? 'Declined'
+ : request.price === null
+ ? 'No quote yet'
+ : `Current quote: ${request.price} spice`}
+
+
+ ))}
+
+ );
+ const request = g.revivalRequests[me.id];
+ const deadNative = me.leaders.filter(
+ (l) => l.faction === me.faction && l.dead && !l.capturedBy,
+ );
+ const hasNative = me.leaders.some(
+ (l) => l.faction === me.faction && nativeAvailable(l),
+ );
+ const eligible = deadNative.filter((l) => hasNative || l.gholaBy);
+ const canRequest =
+ !g.revival.prevented &&
+ !me.leaderRevived &&
+ (hasNative || eligible.some((l) => l.gholaBy));
+ return (
+
+
Request a leader revival or ghola buyback
+
+ A request and its quoted price are visible only to you and Tleilaxu. No
+ spice is paid until you accept and the response resolves.
+
+ {request ? (
+ <>
+
+ {name(request.leader)} ·{' '}
+ {request.declined
+ ? 'Request declined'
+ : request.price === null
+ ? 'Awaiting a quote'
+ : `${request.price} spice`}
+
+ {request.price !== null && !request.declined && (
+
(me.spice ?? 0)}
+ onClick={() => act({ type: 'acceptLeaderRevival' })}
+ >
+ Accept revival price
+
+ )}
+
act({ type: 'cancelLeaderRequest' })}
+ >
+ Withdraw request
+
+ >
+ ) : canRequest && (eligible.length > 0 || me.kwisatz?.dead) ? (
+ <>
+
+ Leader
+ setSelection(e.target.value)}
+ >
+ Choose a dead leader
+ {eligible.map((l) => (
+
+ {l.name}
+ {l.gholaBy ? ' · ghola buyback' : ''}
+
+ ))}
+ {me.kwisatz?.dead && (
+ Kwisatz Haderach
+ )}
+
+
+
+ act({ type: 'requestLeaderRevival', leader: selection })
+ }
+ >
+ Request a quote
+
+ >
+ ) : (
+
+ Early revival needs an available native leader. A dead foreign ghola
+ requires a buyback agreement; other leaders use ordinary revival when
+ all are unavailable.
+
+ )}
+
+ );
+}
diff --git a/components/richese-allied-no-field.tsx b/components/richese-allied-no-field.tsx
new file mode 100644
index 00000000..17d62230
--- /dev/null
+++ b/components/richese-allied-no-field.tsx
@@ -0,0 +1,279 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { territory, MOBILE_STRONGHOLD } from '@/game/board';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+
+export function RicheseAlliedNoFieldControls({
+ game,
+ act,
+ busy,
+ destination,
+ sector,
+ onSectorChange,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+ destination: string;
+ sector: number;
+ onSectorChange: (sector: number) => void;
+}) {
+ const id = useId();
+ const [token, setToken] = useState('');
+ const [payer, setPayer] = useState('');
+ const info = game.richeseNoField;
+ const me = game.players.find((player) => player.id === game.me);
+ const ally = game.players.find((player) => player.id === me?.ally);
+ if (!info || info.owner !== game.me || !info.private || !me || !ally)
+ return null;
+ const offer = info.allyOffer;
+ if (offer?.blocked)
+ return (
+
+ This allied shipment offer is no longer available: {offer.blocked} Your
+ ally may decline it without payment or token changes.
+
+ );
+ if (offer)
+ return (
+
+ Your private No-Field {offer.value} offer to {ally.name}: {offer.amount}{' '}
+ forces in {territory(offer.territory).name}, sector {offer.sector}.{' '}
+ {offer.payer === 'both'
+ ? 'Each ally pays 1 spice'
+ : `${game.players.find((player) => player.id === offer.payer)?.name} pays ${offer.cost} spice`}{' '}
+ if the shipment completes. Declining costs nothing.
+
+ );
+ if (game.phase !== 5 || game.active !== ally.id || ally.shipped) return null;
+ const available = info.private.tokens.filter(
+ (candidate) => candidate.id !== info.private!.lastShipped,
+ );
+ const selected =
+ available.find((candidate) => candidate.id === token) ?? available[0];
+ const target = territory(destination);
+ const cost = target.type === 'stronghold' ? 1 : 2;
+ const selectedPayer =
+ payer === 'both' && cost === 2
+ ? 'both'
+ : payer === ally.id
+ ? ally.id
+ : me.id;
+ const reasons: string[] = [];
+ if (!info.canOfferAlly)
+ reasons.push(
+ info.allyOfferBlock ?? 'An allied No-Field offer is unavailable now.',
+ );
+ if (!selected)
+ reasons.push('Choose a token different from the last one used.');
+ if (destination === MOBILE_STRONGHOLD)
+ reasons.push('Direct shipment into the mobile stronghold is unavailable.');
+ if (!target.sectors.includes(sector))
+ reasons.push('Choose a sector in this territory.');
+ if (sector === game.storm)
+ reasons.push('You cannot ship into the storm sector.');
+ const ownCost =
+ selectedPayer === 'both' ? 1 : selectedPayer === me.id ? cost : 0;
+ if ((me.spice ?? 0) < ownCost)
+ reasons.push('You do not have enough spice to pay for this shipment.');
+ return (
+
+
+ Offer {ally.name} a No-Field shipment
+
+
+
+ This replaces your ally’s normal shipment if accepted. The chosen
+ value and exact force count are shared privately with your ally. The
+ token reveals when the shipment resolves. No payment or existing
+ marker changes occur on decline.
+
+ {info.private.deployed && (
+
+ If accepted and allowed, your existing concealed No-Field must
+ reveal before your ally’s shipment.
+
+ )}
+
Private token
+
setToken(event.target.value)}
+ >
+ {info.private.tokens.map((candidate) => (
+
+ No-Field {candidate.value}
+ {candidate.id === info.private!.lastShipped
+ ? ' · cannot repeat'
+ : ''}
+
+ ))}
+
+
Destination: {target.name}. Select another territory on the map.
+
Sector
+
onSectorChange(Number(event.target.value))}
+ >
+ {target.sectors.map((value) => (
+
+ {value}
+ {value === game.storm ? ' · storm' : ''}
+
+ ))}
+
+
Pay the {cost} spice cost from
+
setPayer(event.target.value)}
+ >
+ {me.name} · you
+ {ally.name} · your ally
+ {cost === 2 && Each ally pays 1 spice }
+
+
+ {cost === 2
+ ? 'Either player can pay the full price, or each ally can pay 1 spice.'
+ : 'One player pays the full price.'}{' '}
+ Each payer’s funds are checked before acceptance and commitment; your
+ ally’s private balance stays hidden.
+
+ {reasons.length > 0 && (
+
+ {reasons.map((reason) => (
+ {reason}
+ ))}
+
+ )}
+
0}
+ aria-describedby={reasons.length ? `${id}-reasons` : undefined}
+ onClick={() => {
+ if (!busy && !reasons.length && selected)
+ act({
+ type: 'offerRicheseNoField',
+ token: selected.id,
+ event: info.event,
+ territory: destination,
+ sector,
+ payer: selectedPayer,
+ });
+ }}
+ >
+ Offer No-Field {selected?.value ?? ''} shipment
+
+
+
+ );
+}
+
+export function RicheseAlliedNoFieldDecision({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const [elite, setElite] = useState(0);
+ const offer = game.richeseNoField?.allyOffer;
+ if (
+ !offer ||
+ offer.recipient !== game.me ||
+ game.decision?.kind !== 'richeseAllyShipment'
+ )
+ return null;
+ const owner = game.players.find((player) => player.id === offer.owner)!;
+ const payer = game.players.find((player) => player.id === offer.payer);
+ const selectedElite = Math.max(
+ offer.eliteMin,
+ Math.min(
+ offer.eliteMax,
+ Number.isFinite(elite) ? Math.trunc(elite) : offer.eliteMin,
+ ),
+ );
+ return (
+
+ {!offer.blocked && (
+
+ {owner.name} offers No-Field {offer.value} to ship {offer.amount} of
+ your forces to {territory(offer.territory).name}, sector{' '}
+ {offer.sector}.{' '}
+ {offer.payer === 'both'
+ ? `You and ${owner.name} each pay 1 spice`
+ : `${payer?.id === game.me ? 'You pay' : `${payer?.name} pays`} ${offer.cost} spice`}{' '}
+ if the shipment completes.
+
+ )}
+
+ The chosen token and force count are private to this alliance until the
+ shipment reveals them. Accepting uses your normal shipment opportunity;
+ declining costs nothing. Any earlier concealed No-Field reveals only if
+ the accepted shipment proceeds.
+
+ {offer.blocked && (
+
+ {offer.blocked} You can decline without cost.
+
+ )}
+ {!offer.blocked && offer.eliteMax > 0 && (
+
+ Elite forces included ({offer.eliteMin}–{offer.eliteMax})
+ setElite(Number(event.target.value))}
+ />
+
+ )}
+
{
+ if (!busy && !offer.blocked)
+ act({
+ type: 'decision',
+ event: offer.event,
+ accept: true,
+ elite: selectedElite,
+ });
+ }}
+ >
+ Accept allied No-Field shipment
+
+
{
+ if (!busy)
+ act({ type: 'decision', event: offer.event, decline: true });
+ }}
+ >
+ Decline offer · no cost
+
+
+ );
+}
diff --git a/components/richese-auctions.tsx b/components/richese-auctions.tsx
new file mode 100644
index 00000000..042d2e43
--- /dev/null
+++ b/components/richese-auctions.tsx
@@ -0,0 +1,429 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import type { RicheseAuctionMethod } from '@/game/richese-auction';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+import { CardInspector, CardRules } from './card-inspector';
+
+type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
+const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
+const methodName = {
+ normal: 'Normal auction',
+ onceAround: 'Once Around',
+ silent: 'Silent auction',
+};
+
+/** Only supplied owner-projected hand/cache identities are selectable. */
+export function RicheseAuctionDecision({ game, act, busy }: Props) {
+ const id = useId();
+ const [cardId, setCardId] = useState('');
+ const [method, setMethod] = useState('onceAround');
+ const [direction, setDirection] = useState<'clockwise' | 'counterclockwise'>(
+ 'clockwise',
+ );
+ const [claim, setClaim] = useState('');
+ const bidding = game.richeseBidding;
+ const decision = game.decision;
+ const me = game.players.find((player) => player.id === game.me);
+ if (
+ !bidding ||
+ !me ||
+ decision?.player !== game.me ||
+ bidding.owner !== game.me ||
+ ![
+ 'richeseBlackMarket',
+ 'richeseDeclaration',
+ 'richeseCache',
+ 'richeseUnbid',
+ ].includes(decision.kind)
+ )
+ return null;
+ const send = (fields: Omit) => {
+ if (!busy) act({ type: 'decision', event: bidding.event, ...fields });
+ };
+ if (decision.kind === 'richeseDeclaration')
+ return (
+
+
+ Choose whether your face-up cache card is auctioned before or after
+ the ordinary cards. This declaration precedes Ixian auction
+ inspection.
+
+ send({ position: 'first' })}
+ >
+ Auction cache card first
+
+ send({ position: 'last' })}
+ >
+ Auction cache card last
+
+
+ );
+ if (decision.kind === 'richeseUnbid') {
+ const room = (me.hand?.length ?? 0) < me.handLimit;
+ return (
+
+
+ No player offered spice. Keep the cache card for free if your hand has
+ room, or remove it from this game.
+
+ {game.richeseAuction?.card && (
+ <>
+
+
+ >
+ )}
+ {!room && (
+ Your hand is full; the card cannot be added.
+ )}
+ send({ keep: true })}
+ >
+ Keep card for free
+
+ send({ keep: false })}
+ >
+ Remove card from game
+
+
+ );
+ }
+ const blackMarket = decision.kind === 'richeseBlackMarket';
+ const cards = blackMarket ? (me.hand ?? []) : (bidding.cache ?? []);
+ const selected = cards.find((card) => card.id === cardId) ?? cards[0];
+ const chosenMethod =
+ !blackMarket && method === 'normal' ? 'onceAround' : method;
+ return (
+
+
+ {blackMarket
+ ? 'Offer a card from your hand without showing its face. Atreides may inspect it. An optional public claim may be truthful or a bluff. If no one pays spice, you keep the card.'
+ : 'Choose one card from your separate cache. Its face is revealed to everyone when offered. Cache cards do not count toward your hand limit.'}
+
+ {bidding.offerBlocked && (
+
+ {bidding.offerBlocked}
+
+ )}
+ {cards.length ? (
+ <>
+ Card to auction
+ setCardId(event.target.value)}
+ >
+ {cards.map((card) => (
+
+ {card.name}
+
+ ))}
+
+ {selected && (
+ <>
+
+
+ >
+ )}
+ Auction method
+
+ setMethod(event.target.value as RicheseAuctionMethod)
+ }
+ >
+ {blackMarket && Normal auction }
+ Once Around
+ Silent auction
+
+ {chosenMethod === 'onceAround' && (
+ <>
+ Bidding direction
+
+ setDirection(event.target.value as typeof direction)
+ }
+ >
+ Clockwise
+ Counterclockwise
+
+ >
+ )}
+ {blackMarket && (
+ <>
+ Public claim (optional)
+ setClaim(event.target.value)}
+ placeholder="What you say you are selling"
+ />
+ >
+ )}
+
+ Karama cannot acquire this auction’s card.{' '}
+ {chosenMethod === 'silent'
+ ? 'Every eligible player submits once; zero is allowed. Equal positive bids use storm order.'
+ : chosenMethod === 'onceAround'
+ ? 'Each eligible player has one bidding opportunity, normally ending with Richese.'
+ : 'Bidding continues in order until the other bidders pass.'}
+
+
+ send({
+ card: selected!.id,
+ method: chosenMethod,
+ direction,
+ ...(blackMarket && claim.trim() ? { claim } : {}),
+ })
+ }
+ >
+ Offer {blackMarket ? 'Black Market' : 'cache'} card
+
+ >
+ ) : (
+ No card is available to offer.
+ )}
+ {blackMarket && (
+ send({ decline: true })}
+ >
+ Skip Black Market
+
+ )}
+
+ );
+}
+
+/** Only projected public bids and this viewer's own sealed submission are rendered. */
+export function RicheseAuctionLot({ game, act, busy }: Props) {
+ const id = useId();
+ const auction = game.richeseAuction;
+ const [draft, setDraft] = useState({ event: '', amount: '', ally: '0' });
+ if (!auction) return null;
+ const me = game.me;
+ const silent = auction.method === 'silent';
+ const minimum = silent ? 0 : auction.bid + 1;
+ const amount =
+ draft.event === auction.event && draft.amount !== ''
+ ? Number(draft.amount)
+ : minimum;
+ const allyPayment = draft.event === auction.event ? Number(draft.ally) : 0;
+ const update = (part: Partial) =>
+ setDraft({
+ event: auction.event,
+ amount: String(amount),
+ ally: String(allyPayment),
+ ...part,
+ });
+ const ownPayment = amount - allyPayment;
+ const sellerBlocked =
+ auction.source === 'blackMarket' && auction.owner === me;
+ const allowedTurn =
+ !auction.outcome &&
+ auction.eligible.includes(me) &&
+ (silent ? !auction.submitted.includes(me) : auction.active === me);
+ const canBid =
+ allowedTurn &&
+ Number.isSafeInteger(amount) &&
+ amount >= minimum &&
+ Number.isSafeInteger(allyPayment) &&
+ allyPayment >= 0 &&
+ allyPayment <= auction.allyAvailable &&
+ ownPayment >= 0 &&
+ ownPayment <= auction.ownAvailable &&
+ (!sellerBlocked || amount === 0);
+ const name = (player: string | null) =>
+ game.players.find((p) => p.id === player)?.name ?? 'Player';
+ const send = (bid: number | null, ally: number) => {
+ if (!busy)
+ act({
+ type: 'richeseBid',
+ event: auction.event,
+ amount: bid,
+ allyPayment: ally,
+ });
+ };
+ return (
+
+
+ {auction.source === 'cache' ? 'Richese cache' : 'Black Market'} ·{' '}
+ {methodName[auction.method]}
+
+ {auction.card ? (
+ <>
+ {auction.card.name}
+ {auction.source === 'blackMarket' && (
+
+ This face is private to you through ownership or authorized
+ inspection.
+
+ )}
+
+
+ >
+ ) : (
+ The Black Market card remains face down.
+ )}
+
+ {name(auction.owner)} is selling. Karama cannot acquire this card.
+
+ {auction.claim && (
+ Seller’s public claim: {auction.claim}
+ )}
+ {silent ? (
+ <>
+
+ {auction.submitted.length} of {auction.eligible.length} bids
+ submitted.
+
+ {auction.ownBid !== null && (
+
+ Your sealed bid: {auction.ownBid} spice, including{' '}
+ {auction.ownAllyPayment} pledged by your ally.
+
+ )}
+ {auction.revealedBids && (
+
+ {Object.entries(auction.revealedBids).map(([player, bid]) => (
+
+ {name(player)}: {bid} spice
+
+ ))}
+
+ )}
+ >
+ ) : (
+
+ Highest bid: {auction.bid} spice
+ {auction.bidder ? ` from ${name(auction.bidder)}` : ''}.
+
+ )}
+ {allowedTurn ? (
+ <>
+
+ Available funding: {auction.ownAvailable} own spice and{' '}
+ {auction.allyAvailable} pledged spice.
+
+ {sellerBlocked && (
+
+ Positive Black Market bids by the seller are not supported.{' '}
+ {silent ? 'Submit zero to continue.' : 'Pass to continue.'}
+
+ )}
+ {!sellerBlocked && (
+ <>
+
+ {silent ? 'Sealed bid' : 'Your bid'} (minimum {minimum})
+
+ update({ amount: event.target.value })}
+ />
+ {auction.allyAvailable > 0 && (
+ <>
+ Pay from ally’s pledge
+ update({ ally: event.target.value })}
+ />
+ >
+ )}
+
+ {canBid
+ ? `Payment if you win: ${ownPayment} own spice + ${allyPayment} ally spice.`
+ : 'Choose a valid bid and funding split within the available spice.'}{' '}
+ {silent
+ ? 'Your submitted amount stays private until all bids are in and cannot be changed.'
+ : 'Raise the current bid or pass.'}
+
+ send(amount, allyPayment)}
+ >
+ {silent ? 'Seal bid' : 'Raise bid'}
+
+ >
+ )}
+ send(silent ? 0 : null, 0)}
+ >
+ {silent ? 'Submit zero' : 'Pass this bid'}
+
+ >
+ ) : (
+
+ {auction.outcome
+ ? 'The auction result is being resolved.'
+ : silent && auction.submitted.includes(me)
+ ? 'Your bid is sealed. Waiting for the other bidders.'
+ : !auction.eligible.includes(me)
+ ? 'You are not eligible to bid on this card.'
+ : `Waiting for ${name(auction.active)} to bid.`}
+
+ )}
+
+ );
+}
diff --git a/components/richese-gift.tsx b/components/richese-gift.tsx
new file mode 100644
index 00000000..577657bd
--- /dev/null
+++ b/components/richese-gift.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { Button } from './ui/button';
+import { CardInspector, CardRules } from './card-inspector';
+
+/** Gift cards come only from this viewer's explicit server projection. */
+export function RicheseGift({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const [selection, setSelection] = useState('');
+ const gift = game.richeseGift;
+ if (!gift) return null;
+ const owner = game.players.find((player) => player.id === gift.owner);
+ const recipient = game.players.find(
+ (player) => player.id === (gift.pending?.recipient ?? gift.recipient),
+ );
+ const mine = gift.owner === game.me;
+ const pending = gift.pending;
+
+ if (pending) {
+ if (![pending.owner, pending.recipient].includes(game.me)) return null;
+ return (
+
+ Richese gift awaiting resolution
+
+ {mine
+ ? 'You are giving'
+ : `${owner?.name ?? 'Richese'} is giving you`}{' '}
+ {pending.card?.name ?? 'a Richese card'}
+ {mine ? ` to ${recipient?.name ?? 'your ally'}` : ''}. Resolve the
+ current response before the card transfers. Inspecting it does not
+ transfer the card.
+
+ {pending.card && (
+
+
+
+
+ )}
+
+ );
+ }
+ if (!mine) return null;
+
+ const card =
+ gift.cards.find((candidate) => candidate.id === selection) ?? gift.cards[0];
+ const reason =
+ gift.blocked ??
+ (!recipient
+ ? 'You need an ally to give a Richese card.'
+ : !card
+ ? 'No Richese card in your hand is available to give.'
+ : null);
+ return (
+
+
+ Give a Richese card to your ally
+
+
+
+ Give one Richese card from your hand to{' '}
+ {recipient?.name ?? 'your ally'}. Your ally must have room. Karama can
+ prevent this alliance power before the card transfers.
+
+ {gift.cards.length > 0 && (
+ <>
+
Card to give
+
setSelection(event.target.value)}
+ >
+ {gift.cards.map((candidate) => (
+
+ {candidate.name}
+
+ ))}
+
+ {card && (
+
+
+
+
+ )}
+ >
+ )}
+ {reason && (
+
+ {reason}
+
+ )}
+
{
+ if (!busy && !reason && card)
+ act({ type: 'richeseGift', card: card.id });
+ }}
+ >
+ Give {card?.name ?? 'a Richese card'} to{' '}
+ {recipient?.name ?? 'your ally'}
+
+ {gift.unavailable.length > 0 && (
+
+
Cards unavailable to give
+
+ {gift.unavailable.map(
+ ({ card: unavailable, reason: unavailableReason }) => (
+
+ {unavailable.name}
+ {unavailableReason}
+
+
+ ),
+ )}
+
+
+ )}
+
+
+ );
+}
diff --git a/components/richese-no-field.tsx b/components/richese-no-field.tsx
new file mode 100644
index 00000000..98a78828
--- /dev/null
+++ b/components/richese-no-field.tsx
@@ -0,0 +1,266 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { faction } from '@/game/catalog';
+import { territory, MOBILE_STRONGHOLD } from '@/game/board';
+import { fighterCount } from '@/game/advisors';
+import { reserveShipmentCost } from '@/game/shipment-price';
+import type { RicheseNoFieldView } from '@/game/richese-no-field';
+import { ShipmentQuote } from './shipment-quote';
+import { Button } from './ui/button';
+
+/** This renderer receives public player data only, never the private token model. */
+export function NoFieldBoardMarkers({
+ players,
+}: {
+ players: readonly {
+ id: string;
+ name: string;
+ faction: string;
+ noField?: RicheseNoFieldView | null;
+ }[];
+}) {
+ return players.flatMap((player) => {
+ const marker = player.noField?.deployed;
+ if (!marker) return [];
+ const t = territory(marker.location.territory);
+ const label = `${player.name}: concealed No-Field in ${t.name}, sector ${marker.location.sector}. Counts as one force until revealed.`;
+ return [
+
+ {label}
+
+
+ NF
+
+ ,
+ ];
+ });
+}
+
+export function RicheseNoFieldControls({
+ game,
+ act,
+ busy,
+ destination,
+ sector,
+ allyPayment,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+ destination: string;
+ sector: number;
+ allyPayment: string;
+}) {
+ const id = useId();
+ const [selection, setSelection] = useState('');
+ const info = game.richeseNoField;
+ const me = game.players.find((player) => player.id === game.me);
+ if (!info || info.owner !== game.me || !info.private || !me) return null;
+ const state = info.private;
+ const available = state.tokens.filter(
+ (token) => token.id !== state.lastShipped,
+ );
+ const selected =
+ available.find((token) => token.id === selection) ?? available[0];
+ const deployed = state.deployed;
+ const selectedTerritory = territory(destination);
+ const rate = {
+ faction: me.faction,
+ halfRate:
+ me.faction === 'guild' ||
+ game.players.some(
+ (player) => player.faction === 'guild' && player.id === me.ally,
+ ) ||
+ game.karamaShipping?.player === me.id,
+ };
+ const cost = reserveShipmentCost(rate, selectedTerritory.type, 1);
+ const share =
+ !me.ally || allyPayment === ''
+ ? Math.max(0, cost - (me.spice ?? 0))
+ : Number(allyPayment);
+ const validShare = Number.isSafeInteger(share) && share >= 0 && share <= cost;
+ const problems: string[] = [];
+ if (!info.canShip)
+ problems.push(info.shipBlock ?? 'No-Field shipment is not available now.');
+ if (!selected) problems.push('No different No-Field token is available.');
+ if (destination === MOBILE_STRONGHOLD)
+ problems.push('You cannot ship directly into the mobile stronghold.');
+ if (
+ selectedTerritory.type === 'stronghold' &&
+ game.players.filter(
+ (player) => player.id !== me.id && fighterCount(player, destination) > 0,
+ ).length >= 2
+ )
+ problems.push('Two other factions already occupy this stronghold.');
+ if (!selectedTerritory.sectors.includes(sector))
+ problems.push('Select a sector in the destination territory.');
+ if (sector === game.storm)
+ problems.push('You cannot ship into the storm sector.');
+ if (!validShare) problems.push(`Choose an ally payment from 0 to ${cost}.`);
+ else {
+ if (share > game.aid.available)
+ problems.push(
+ `Only ${game.aid.available} pledged ally spice is available.`,
+ );
+ if (cost - share > (me.spice ?? 0))
+ problems.push('Your spice does not cover the chosen payment.');
+ }
+ const showShipment = game.phase === 5 && game.active === me.id && !me.shipped;
+ return (
+
+
+ Your No-Field tokens
+
+
+
+ Your token values are private until revealed. A concealed marker
+ counts as one force, even when its value is zero. Physical forces
+ remain in reserves until revelation.
+
+
+ {state.tokens.map((token) => (
+
+ No-Field {token.value}
+
+ {deployed?.tokenId === token.id
+ ? 'Concealed on the board'
+ : state.lastShipped === token.id
+ ? 'Last used · cannot repeat'
+ : 'Available'}
+
+
+ ))}
+
+ {deployed && (
+ <>
+
+ Concealed at {territory(deployed.location.territory).name}, sector{' '}
+ {deployed.location.sector}. Use the movement controls to move the
+ marker alone or with physical forces from that territory.
+
+
{
+ if (!busy && info.canReveal)
+ act({
+ type: 'revealNoField',
+ event: info.event,
+ token: deployed.tokenId,
+ });
+ }}
+ >
+ Reveal your No-Field
+
+ {!info.canReveal && (
+
+ {info.revealBlock ??
+ 'Voluntary revelation is not available now.'}
+
+ )}
+ >
+ )}
+ {showShipment && (
+ <>
+
Private token to ship
+
setSelection(event.target.value)}
+ >
+ {state.tokens.map((token) => (
+
+ No-Field {token.value}
+ {token.id === state.lastShipped ? ' · cannot repeat' : ''}
+
+ ))}
+
+
+ Destination: {selectedTerritory.name}, sector {sector}. Choose
+ another destination on the map or in the shipment controls. This
+ replaces your normal shipment at the price of one force.
+
+
+
0}
+ aria-describedby={`${id}-quote`}
+ onClick={() => {
+ if (!busy && problems.length === 0 && selected)
+ act({
+ type: 'ship',
+ noField: selected.id,
+ event: info.event,
+ territory: destination,
+ sector,
+ allyPayment: share,
+ });
+ }}
+ >
+ Ship concealed No-Field {selected?.value ?? ''} · {cost} spice
+
+ >
+ )}
+
+
+ );
+}
diff --git a/components/richese-special-karama.tsx b/components/richese-special-karama.tsx
new file mode 100644
index 00000000..af874a8d
--- /dev/null
+++ b/components/richese-special-karama.tsx
@@ -0,0 +1,121 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+import { Button } from './ui/button';
+import { CardInspector, CardRules } from './card-inspector';
+
+export function RicheseSpecialKarama({
+ game,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const [activation, setActivation] = useState('');
+ const [selection, setSelection] = useState('');
+ const purchase = game.richeseSpecialKarama;
+ if (!purchase) return null;
+ const karama =
+ purchase.karamas.find((card) => card.id === activation) ??
+ purchase.karamas[0];
+ const acquired =
+ purchase.cards.find((card) => card.id === selection) ?? purchase.cards[0];
+ const payee = purchase.payee
+ ? (game.players.find((player) => player.id === purchase.payee)?.name ??
+ 'Emperor')
+ : 'the Spice Bank';
+ const reason =
+ purchase.blocked ??
+ (!karama
+ ? 'You need an available Karama card.'
+ : !acquired
+ ? 'No cache card is available for this purchase.'
+ : null);
+ return (
+
+
+ Richese special Karama: buy from your cache
+
+
+
+ Spend a Karama and 3 of your spice to secretly choose a cache card for
+ your hand. This uses your once-per-game special power. Payment goes to{' '}
+ {payee}.
+
+
+ The special purchase cannot be canceled by Karama. Emperor income has
+ its separate response. The remaining Richese card effects are still
+ being implemented; inspect a card before choosing it.
+
+ {purchase.karamas.length > 0 && (
+ <>
+
Karama to spend
+
setActivation(event.target.value)}
+ >
+ {purchase.karamas.map((card) => (
+
+ {card.name}
+
+ ))}
+
+ {karama &&
}
+ >
+ )}
+ {purchase.cards.length > 0 && (
+ <>
+
Private cache choice
+
setSelection(event.target.value)}
+ >
+ {purchase.cards.map((card) => (
+
+ {card.name}
+
+ ))}
+
+ {acquired && (
+
+
+
+
+ )}
+ >
+ )}
+ {reason && (
+
+ {reason}
+
+ )}
+
{
+ if (!busy && !reason && karama && acquired)
+ act({
+ type: 'card',
+ mode: 'special',
+ card: karama.id,
+ acquire: acquired.id,
+ });
+ }}
+ >
+ Spend Karama and 3 spice to buy {acquired?.name ?? 'a cache card'}
+
+
+
+ );
+}
diff --git a/components/rules-reference.tsx b/components/rules-reference.tsx
new file mode 100644
index 00000000..3ce29dc6
--- /dev/null
+++ b/components/rules-reference.tsx
@@ -0,0 +1,338 @@
+'use client';
+import Link from 'next/link';
+import { StrongholdCardGallery } from './stronghold-cards';
+import { createStrongholdCards } from '@/game/stronghold-cards';
+import { useSearchParams } from 'next/navigation';
+import { Suspense, useState, useEffect, useRef } from 'react';
+import { Search, ArrowUpRight, BookOpen } from 'lucide-react';
+import { Input } from '@/components/ui/input';
+import { RULE_TOPICS } from '@/game/reference';
+import { BattleWheel } from './battle-wheel';
+import { RICHESE_CARD_DEFINITIONS } from '@/game/richese-cards';
+import { CardInspector, CardRules } from './card-inspector';
+
+export function RulesReference() {
+ return (
+ Loading the rules reference… }
+ >
+
+
+ );
+}
+
+function RulesReferenceContent() {
+ const searchParams = useSearchParams();
+ const topicQuery = searchParams.get('topic');
+ const [query, setQuery] = useState('');
+ const [practiceDial, setPracticeDial] = useState(3);
+ const [category, setCategory] = useState('All topics');
+ const [opened, setOpened] = useState>({});
+ const pendingScroll = useRef(null);
+ useEffect(() => {
+ const reveal = () => {
+ const id = topicQuery || window.location.hash.slice(1);
+ if (!RULE_TOPICS.some((t) => t.id === id)) return;
+ pendingScroll.current = id;
+ setQuery('');
+ setCategory('All topics');
+ setOpened((previous) => ({ ...previous, [id]: true }));
+ };
+ reveal();
+ window.addEventListener('hashchange', reveal);
+ return () => window.removeEventListener('hashchange', reveal);
+ }, [topicQuery]);
+ // Scroll only after React has rendered the destination with cleared filters.
+ useEffect(() => {
+ const id = pendingScroll.current;
+ if (!id) return;
+ const article = document.getElementById(id);
+ if (!article) return;
+ pendingScroll.current = null;
+ article.scrollIntoView({ block: 'start' });
+ });
+ const openTopic = (id: string) => {
+ pendingScroll.current = id;
+ setQuery('');
+ setCategory('All topics');
+ setOpened((previous) => ({ ...previous, [id]: true }));
+ };
+ const categories = [
+ 'All topics',
+ ...new Set(RULE_TOPICS.map((t) => t.category)),
+ ];
+ const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
+ const topics = RULE_TOPICS.filter(
+ (t) =>
+ (category === 'All topics' || t.category === category) &&
+ terms.every((term) =>
+ [
+ t.title,
+ t.summary,
+ ...t.steps,
+ t.example ?? '',
+ ...(t.checklist ?? []).flatMap((item) => [
+ item.area,
+ item.status,
+ item.detail,
+ ...(item.evidence ?? []),
+ ]),
+ ]
+ .join(' ')
+ .toLowerCase()
+ .includes(term),
+ ),
+ );
+ return (
+
+
+ ← Return to Arrakis
+
+ THE MENTAT’S REFERENCE
+
+
+ Know the rules.
+
+ Choose your moment.
+
+
+ Find a phase, card or faction. Follow the action guides, inspect
+ examples and move directly between related rules.
+
+
+ This reference is growing with the game. Advanced play and expansion
+ rules are unfinished.{' '}
+
+ View the implementation ledger
+
+ {' · '}
+
+ Open feature checklists
+
+
+
+
+
+
+
+ {topics.length} {topics.length === 1 ? 'topic' : 'topics'}
+ {query ? ` matching “${query}”` : ''}
+
+ {topics.length === 0 && (
+ No matching topics. Try a card name or choose All topics.
+ )}
+ {topics.map((topic) => (
+
+ 0 || !!opened[topic.id]}
+ onToggle={(event) => {
+ if (terms.length) return;
+ const isOpen = event.currentTarget.open;
+ setOpened((previous) =>
+ previous[topic.id] === isOpen
+ ? previous
+ : { ...previous, [topic.id]: isOpen },
+ );
+ }}
+ >
+
+ {topic.category}
+ {topic.title}
+
+ {topic.coverage}
+
+ {topic.summary}
+
+
+
+ {topic.steps.map((step) => (
+ {step}
+ ))}
+
+ {topic.id === 'stronghold-cards' && (
+
+ )}
+ {topic.id === 'richese-cards' && (
+
+ {RICHESE_CARD_DEFINITIONS.map(({ card, printedType }) => (
+
+
+ {card.name}
+
+
+ {printedType} · 1 card
+
+
+
+
+ ))}
+
+ )}
+ {topic.checklist && (
+
+ Feature checklist
+
+
+
+ {topic.title}: implementation, controls, AI,
+ documentation and verification
+
+
+
+
+ Area
+
+
+ Status
+
+
+ Scope and evidence
+
+
+
+
+ {topic.checklist.map((item) => (
+
+
+ {item.area}
+
+ {item.status}
+
+ {item.detail}
+ {item.evidence && (
+
+ {item.evidence.map((file) => (
+
+
+ {file}
+
+
+ ))}
+
+ )}
+
+
+ ))}
+
+
+
+
+ )}
+ {topic.id === 'battle' && (
+
+ Try a basic battle wheel
+
+ Practice with ten ordinary forces and a strength-five
+ leader. Use the slider, arrow keys or number field. In a
+ game, your choice stays private until the plans are
+ revealed.
+
+
+
+ Practice dial
+
+
+ setPracticeDial(
+ Math.min(10, Math.max(0, Math.round(value))),
+ )
+ }
+ max={10}
+ step={1}
+ />
+
+
+ If the leader survives, your total is {practiceDial} + 5
+ = {practiceDial + 5}. In a normal victory you lose the{' '}
+ {practiceDial} dialed forces; in a defeat you lose all
+ ten. Weapon, traitor and faction effects can change the
+ result.
+
+
+ This practice wheel does not change a live game.
+
+
+ )}
+ {topic.example && (
+
+ Example
+ {topic.example}
+
+ )}
+ {topic.related && (
+
+ {topic.related.map((id) => (
+ {
+ openTopic(id);
+ }}
+ >
+ {RULE_TOPICS.find((t) => t.id === id)?.title ?? id}{' '}
+
+
+ ))}
+
+ )}
+
openTopic(topic.id)}
+ >
+ Link to this topic
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/seat-autopilot.tsx b/components/seat-autopilot.tsx
new file mode 100644
index 00000000..e5f767a4
--- /dev/null
+++ b/components/seat-autopilot.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { useId, useState } from 'react';
+import { Bot, UserRound } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import type { Action, GameView } from '@/game/engine';
+import {
+ BOT_DESCRIPTIONS,
+ DIFFICULTIES,
+ type Difficulty,
+} from '@/game/bot-profiles';
+
+export type SeatAutopilotProps = {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+};
+
+/** Voluntary control of the current human seat; the action never names another player. */
+export function SeatAutopilot({ game, act, busy }: SeatAutopilotProps) {
+ const id = useId();
+ const [difficulty, setDifficulty] = useState('Medium');
+ const own = game.players.find((player) => player.id === game.me);
+ if (!own || own.bot || !['setup', 'playing'].includes(game.status))
+ return null;
+
+ if (own.autopilot)
+ return (
+
+
+
+
+ AI autopilot · {own.autopilot}
+
+
+ AI is making this seat’s decisions using only its available
+ information, with about 1.5 seconds between decisions. You keep the
+ seat and its recovery setup. Taking back control stops future AI
+ decisions; completed decisions remain in the game.
+
+
+ {
+ if (!busy) act({ type: 'setAutopilot', difficulty: null });
+ }}
+ >
+
+ Take back control
+
+
+ );
+
+ return (
+
+
+ Let AI play for me
+
+
+
+ Autopilot makes setup and game decisions for your seat using only
+ information that seat can see, with about 1.5 seconds between
+ decisions. It may make irreversible decisions, including spending
+ spice, moving forces and committing battle plans. You retain your seat
+ and recovery setup and can take back control. Completed decisions
+ cannot be undone by turning autopilot off.
+
+
+
+ AI difficulty
+
+
{
+ const value = event.target.value;
+ if (DIFFICULTIES.some((choice) => choice === value))
+ setDifficulty(value as Difficulty);
+ }}
+ >
+ {DIFFICULTIES.map((choice) => (
+
+ {choice}
+
+ ))}
+
+
+ {BOT_DESCRIPTIONS[difficulty]}
+
+
+
{
+ if (!busy) act({ type: 'setAutopilot', difficulty });
+ }}
+ >
+
+ Start autopilot
+
+
+
+ );
+}
diff --git a/components/seat-recovery.tsx b/components/seat-recovery.tsx
new file mode 100644
index 00000000..5358c969
--- /dev/null
+++ b/components/seat-recovery.tsx
@@ -0,0 +1,451 @@
+'use client';
+
+import { useId, useRef, useState } from 'react';
+import { Copy, Eye, EyeOff, KeyRound } from 'lucide-react';
+import type { GameView } from '@/game/engine';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { requestJson, requestMayHaveCompleted } from '@/lib/client-request';
+import {
+ createRecoveryAttempt,
+ createRecoveryKit,
+ parseRecoveryKit,
+ serializeRecoveryKit,
+ type SeatRecoveryAttempt,
+ type SeatRecoveryKit,
+} from '@/lib/seat-recovery';
+
+type Restored = (view: GameView) => void;
+type SetupResponse = { view: GameView; recoveryConfigured: true };
+type ClaimResponse = { view: GameView; recovered: true; replayed: boolean };
+
+/** Mount with key={`${game.code}:${game.me}`} to separate private owner contexts. */
+export function SeatRecoverySetup({
+ game,
+ onRestored,
+ onPending,
+ disabled = false,
+}: {
+ game: GameView;
+ onRestored: Restored;
+ onPending?: (pending: boolean) => void;
+ disabled?: boolean;
+}) {
+ const id = useId();
+ const pending = useRef(false);
+ const [draft, setDraft] = useState(null);
+ const [saved, setSaved] = useState(false);
+ const [confirmed, setConfirmed] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [uncertain, setUncertain] = useState(false);
+ const [message, setMessage] = useState('');
+ const own = game.players.find((player) => player.id === game.me);
+ const kit =
+ draft?.roomCode === game.code && draft.playerId === game.me ? draft : null;
+ if (!own) return null;
+
+ async function copyKit() {
+ if (!kit) return;
+ try {
+ await navigator.clipboard.writeText(serializeRecoveryKit(kit));
+ setMessage(
+ 'Private kit copied. Save it somewhere secure before continuing.',
+ );
+ } catch {
+ setMessage(
+ 'Copy was unavailable. Select the complete kit below and copy it manually.',
+ );
+ }
+ }
+
+ async function saveKey() {
+ if (!kit || !saved || confirmed || disabled || pending.current) return;
+ let confirmedView: GameView | undefined;
+ pending.current = true;
+ setBusy(true);
+ onPending?.(true);
+ setMessage('Saving your recovery key…');
+ try {
+ const result = await requestJson(
+ `/api/rooms/${kit.roomCode}/control`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ type: 'setRecoveryKey',
+ version: game.version,
+ recoverySecret: kit.recoverySecret,
+ }),
+ },
+ );
+ if (
+ result.recoveryConfigured !== true ||
+ result.view.code !== kit.roomCode ||
+ result.view.me !== kit.playerId
+ )
+ throw new Error('The server did not confirm this seat’s recovery key.');
+ setConfirmed(true);
+ setUncertain(false);
+ setMessage(
+ 'Recovery key saved. Keep your private kit; it is needed if this browser loses its seat.',
+ );
+ confirmedView = result.view;
+ } catch (error) {
+ const mayHaveSaved = requestMayHaveCompleted(error);
+ setUncertain(mayHaveSaved);
+ setMessage(
+ mayHaveSaved
+ ? 'Saving was not confirmed and may have completed. Keep this same kit. Retry saving to confirm it; no retry has been sent automatically.'
+ : `${(error as Error).message} Your kit remains below. Review the current table and retry when ready.`,
+ );
+ } finally {
+ pending.current = false;
+ setBusy(false);
+ onPending?.(false);
+ }
+ if (confirmedView) onRestored(confirmedView);
+ }
+
+ return (
+
+
+ Protect your saved seat
+
+
+ Create or replace a private recovery key for {own.name}. Anyone with the
+ kit can recover this seat, revealing its private information and
+ revoking its old browser sessions. Keep it separate from the public
+ invitation.
+
+
+ This restores your own seat after cookie loss. A replacement key
+ invalidates the previous kit. The kit stays in memory here; save it
+ before refreshing or closing this page.
+
+ {!kit ? (
+ {
+ try {
+ setDraft(createRecoveryKit(game.code, game.me));
+ setSaved(false);
+ setConfirmed(false);
+ setUncertain(false);
+ setMessage('Save this kit before enabling its recovery key.');
+ } catch (error) {
+ setMessage((error as Error).message);
+ }
+ }}
+ >
+
+ Generate private recovery kit
+
+ ) : (
+
+
+ Private recovery kit for room {kit.roomCode}
+
+
+
+ This contains a secret key. Save the entire kit privately; do not
+ put it in chat or the invitation.
+
+
void copyKit()}>
+
+ Copy private kit
+
+
+ setSaved(event.target.checked)}
+ />
+ I have saved the complete private kit outside this page.
+
+
+ void saveKey()}
+ >
+ {confirmed
+ ? 'Recovery key confirmed'
+ : busy
+ ? 'Saving…'
+ : uncertain
+ ? 'Retry saving this key'
+ : 'Enable this recovery key'}
+
+ {!uncertain && (
+ {
+ setDraft(null);
+ setSaved(false);
+ setConfirmed(false);
+ setMessage(
+ confirmed
+ ? 'Saved kit hidden. Keep your private copy for recovery.'
+ : 'Unsaved kit discarded.',
+ );
+ }}
+ >
+ {confirmed ? 'Done — hide saved kit' : 'Discard unsaved kit'}
+
+ )}
+
+
+ )}
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+}
+
+export function SeatRecoveryClaim({
+ initialCode,
+ onRestored,
+ onPending,
+ onUncertain,
+ disabled = false,
+}: {
+ initialCode?: string;
+ onRestored: Restored;
+ onPending?: (pending: boolean) => void;
+ onUncertain?: (uncertain: boolean) => void;
+ disabled?: boolean;
+}) {
+ const id = useId();
+ const pending = useRef(false);
+ const [kitText, setKitText] = useState('');
+ const [showKit, setShowKit] = useState(false);
+ const [prepared, setPrepared] = useState<{
+ room: string;
+ attempt: SeatRecoveryAttempt;
+ } | null>(null);
+ const [busy, setBusy] = useState(false);
+ const [uncertain, setUncertain] = useState(false);
+ const [attempted, setAttempted] = useState(false);
+ const [abandonAcknowledged, setAbandonAcknowledged] = useState(false);
+ const [message, setMessage] = useState('');
+
+ function inspectKit() {
+ if (disabled || pending.current) return;
+ try {
+ const kit = parseRecoveryKit(kitText);
+ setPrepared({ room: kit.roomCode, attempt: createRecoveryAttempt(kit) });
+ setAttempted(false);
+ setUncertain(false);
+ setShowKit(false);
+ setMessage('Kit read. Review the room and seat before recovering.');
+ } catch (error) {
+ setMessage((error as Error).message);
+ }
+ }
+
+ async function recover() {
+ if (!prepared || disabled || pending.current) return;
+ let confirmedView: GameView | undefined;
+ pending.current = true;
+ setBusy(true);
+ onPending?.(true);
+ setAttempted(true);
+ setAbandonAcknowledged(false);
+ setMessage('Recovering your seat…');
+ try {
+ const result = await requestJson(
+ `/api/rooms/${prepared.room}/control`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ // The prepared UUID and session token are retained unchanged after a timeout.
+ body: JSON.stringify(prepared.attempt),
+ },
+ );
+ if (
+ result.recovered !== true ||
+ result.view.code !== prepared.room ||
+ result.view.me !== prepared.attempt.playerId
+ )
+ throw new Error('The server did not confirm the recovered seat.');
+ setKitText('');
+ setPrepared(null);
+ setShowKit(false);
+ setAttempted(false);
+ setUncertain(false);
+ onUncertain?.(false);
+ setMessage(
+ 'Seat recovered in this browser. Previous browser sessions for this seat have been revoked.',
+ );
+ confirmedView = result.view;
+ } catch (error) {
+ // A later rejection cannot prove that an earlier ambiguous attempt failed.
+ // Retain that exact receipt until a response confirms this recovered seat.
+ const mayHaveRecovered = uncertain || requestMayHaveCompleted(error);
+ setUncertain(mayHaveRecovered);
+ onUncertain?.(mayHaveRecovered);
+ setMessage(
+ mayHaveRecovered
+ ? 'Recovery was not confirmed and may have completed. Retry this same request below. Keep this page open to preserve its exact retry details; no retry is automatic.'
+ : `${(error as Error).message} You can retry the same request or inspect a different saved kit.`,
+ );
+ } finally {
+ pending.current = false;
+ setBusy(false);
+ onPending?.(false);
+ }
+ if (confirmedView) onRestored(confirmedView);
+ }
+
+ return (
+
+
+ Recover a saved seat
+
+
+ Use the private kit saved before this browser lost its seat. Recovery
+ restores that seat’s private information and revokes its previous
+ browser sessions.
+
+ {initialCode && (
+
+ Invitation room: {initialCode}. The saved kit determines which room
+ and seat you recover.
+
+ )}
+ Paste your private recovery kit
+
+ setKitText(event.target.value)}
+ maxLength={4096}
+ autoComplete="off"
+ spellCheck={false}
+ aria-describedby={`${id}-claim-help`}
+ className="min-w-0 flex-1"
+ />
+ setShowKit(!showKit)}
+ >
+ {showKit ? : }
+ {showKit ? 'Hide kit' : 'Show kit'}
+
+
+
+ Keep this kit private. Reading it checks its format; Recover this seat
+ submits it to restore your browser session.
+
+ {!prepared ? (
+
+ Read recovery kit
+
+ ) : (
+
+
+ Room to recover
+ {prepared.room}
+ Saved seat identifier
+ {prepared.attempt.playerId}
+
+
+ void recover()}>
+ {busy
+ ? 'Recovering…'
+ : attempted
+ ? 'Retry same recovery request'
+ : 'Recover this seat'}
+
+ {!uncertain && (
+ {
+ setPrepared(null);
+ setAttempted(false);
+ setMessage('Paste or inspect your saved kit.');
+ }}
+ >
+ Use a different kit
+
+ )}
+
+ {uncertain && (
+
+
+ If this exact retry can no longer succeed, you can deliberately
+ abandon its retry details. This does not cancel a completed
+ recovery; previous browser sessions may already have been
+ revoked. You need a saved recovery kit to recover again.
+
+
+
+ setAbandonAcknowledged(event.target.checked)
+ }
+ />
+
+ I understand that abandoning drops the exact retry details and
+ does not undo recovery.
+
+
+
{
+ if (disabled || pending.current || !abandonAcknowledged)
+ return;
+ setPrepared(null);
+ setAttempted(false);
+ setUncertain(false);
+ setAbandonAcknowledged(false);
+ setShowKit(false);
+ onUncertain?.(false);
+ setMessage(
+ 'Recovery retry details abandoned. The private kit remains in the input; read it again or paste a newer saved kit before starting another recovery.',
+ );
+ }}
+ >
+ Abandon recovery attempt
+
+
+ )}
+
+ )}
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+}
diff --git a/components/shipment-promises.tsx b/components/shipment-promises.tsx
new file mode 100644
index 00000000..cf4ed4bc
--- /dev/null
+++ b/components/shipment-promises.tsx
@@ -0,0 +1,101 @@
+'use client';
+import type { Action, GameView } from '@/game/engine';
+import { liveShipmentPromises } from '@/game/shipment-promises';
+import { territory } from '@/game/board';
+import { reserveShipmentCost, guildShipmentCost } from '@/game/shipment-price';
+import { Button } from './ui/button';
+
+export function ShipmentPromises({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const promises = liveShipmentPromises(g.shipmentPromises, g.me, g.turn);
+ if (!promises.length) return null;
+ const me = g.players.find((p) => p.id === g.me)!;
+ const next = g.shipmentCompletion?.actions[0];
+ const shipping = next && (next.type === 'ship' || next.type === 'guildShip');
+ const cost = shipping
+ ? next.type === 'guildShip'
+ ? guildShipmentCost(
+ territory(String(next.territory)).type,
+ Number(next.amount),
+ )
+ : reserveShipmentCost(
+ {
+ faction: me.faction,
+ halfRate:
+ me.faction === 'guild' ||
+ g.players.some(
+ (p) => p.faction === 'guild' && p.id === me.ally,
+ ) ||
+ g.karamaShipping?.player === me.id,
+ },
+ territory(String(next.territory)).type,
+ Number(next.amount),
+ )
+ : null;
+ const share = shipping
+ ? Number(next.allyPayment ?? Math.max(0, cost! - (me.spice ?? 0)))
+ : 0;
+ const description = next
+ ? shipping
+ ? `Ship ${Number(next.amount)} forces to ${territory(String(next.territory)).name}, sector ${Number(next.sector)}`
+ : next.type === 'pledgeAid'
+ ? 'Reclaim your unused ally pledge'
+ : `Play ${me.hand?.find((c) => c.id === next.card)?.name ?? 'your preparation card'}${next.amount ? ` to revive ${Number(next.amount)} forces` : ' for your shipment discount'}`
+ : '';
+ return (
+
+ Your shipment promises
+
+ {promises.map((p, index) => (
+
+ {p.answer ? 'Ship' : 'Do not ship'} at least {p.minimum} physical
+ forces from reserves to {territory(p.territory).name} this turn.
+
+ ))}
+
+ {promises.some((p) => p.answer) && (
+
+ Complete a qualifying shipment before ground movement or finishing
+ your turn. You can choose any legal count, sector and funding that
+ honor your answers.
+
+ )}
+ {next && (
+
+ Suggested next step · private
+ {description}.
+ {cost !== null && (
+
+ Cost: {cost} spice · Your payment: {cost - share} · Ally pledge:{' '}
+ {share}.
+
+ )}
+ act(next)}
+ >
+ {description}
+
+
+ )}
+
+ );
+}
diff --git a/components/shipment-quote.tsx b/components/shipment-quote.tsx
new file mode 100644
index 00000000..271031bf
--- /dev/null
+++ b/components/shipment-quote.tsx
@@ -0,0 +1,89 @@
+export type ShipmentQuoteProps = {
+ id?: string;
+ label?: string;
+ physicalForces?: number;
+ quote: {
+ cost: number;
+ normalCost: number;
+ ownPayment: number;
+ pledgedPayment: number;
+ } | null;
+ funding: {
+ ownSpice: number;
+ pledgedSpice: number;
+ };
+ unavailableReasons?: readonly string[];
+};
+
+/** Displays only caller-supplied prices and private funding already authorized for
+ * this viewer. The caller validates the selection and controls action availability.
+ */
+export function ShipmentQuote({
+ id,
+ label = 'Reserve shipment',
+ physicalForces,
+ quote,
+ funding,
+ unavailableReasons = [],
+}: ShipmentQuoteProps) {
+ return (
+
+
+ {label !== 'Reserve shipment' && (
+
{label}
+ )}
+
+ {quote ? `Shipment cost: ${quote.cost} spice` : 'Shipment cost'}
+
+ {quote ? (
+ quote.normalCost !== quote.cost && (
+
+ Normal cost: {quote.normalCost} spice. The applicable rate is
+ included in your shipment cost.
+
+ )
+ ) : (
+
+ Choose valid forces and funding to see the payment breakdown.
+
+ )}
+
+
+ {physicalForces !== undefined && (
+ <>
+ Physical forces selected
+ {physicalForces}
+ >
+ )}
+ Your available spice
+ {funding.ownSpice}
+ Available ally pledge
+ {funding.pledgedSpice}
+ {quote && (
+ <>
+ Your payment
+ {quote.ownPayment}
+ Ally pledge used
+
+ {quote.pledgedPayment}
+
+ >
+ )}
+
+ {unavailableReasons.length > 0 && (
+
+
Shipment unavailable
+
+ {unavailableReasons.map((reason, index) => (
+ {reason}
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/components/spice-card-inspector.tsx b/components/spice-card-inspector.tsx
new file mode 100644
index 00000000..55200824
--- /dev/null
+++ b/components/spice-card-inspector.tsx
@@ -0,0 +1,267 @@
+'use client';
+
+import { useId } from 'react';
+import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
+import { Eye, X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogDescription,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import type { SpiceCard } from '@/game/cards';
+import { territory } from '@/game/board';
+import { PHASE_HELP } from '@/game/reference';
+
+/** Only the supplied visible face is read. No deck, discard pile or game is accepted. */
+export type SpiceCardInspectorProps = {
+ card: Readonly;
+ context?: string;
+};
+
+export function spiceCardTitle(card: Readonly): string {
+ if ('territory' in card)
+ return `${territory(card.territory).name} · ${card.amount} spice · sector ${card.sector}`;
+ if ('sandtrout' in card) return 'Sandtrout';
+ return `Shai-Hulud${card.thumper ? ' · Thumper encounter' : ''}${card.suppressed ? ' · suppressed' : ''}`;
+}
+
+/** Original vector illustration, with no claim to reproduce a printed card face. */
+function SpiceIllustration({
+ kind,
+}: {
+ kind: 'territory' | 'worm' | 'sandtrout';
+}) {
+ const paint = `${useId()}-dune`;
+ return (
+
+
+
+
+
+
+
+
+
+
+ {kind === 'territory' ? (
+
+
+
+
+
+ ) : kind === 'worm' ? (
+
+
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+}
+
+export function SpiceCardInspector({ card, context }: SpiceCardInspectorProps) {
+ const kind =
+ 'territory' in card
+ ? 'territory'
+ : 'sandtrout' in card
+ ? 'sandtrout'
+ : 'worm';
+ const title = spiceCardTitle(card);
+ const faceName =
+ 'territory' in card
+ ? territory(card.territory).name
+ : kind === 'worm'
+ ? 'Shai-Hulud'
+ : 'Sandtrout';
+ const description =
+ kind === 'territory'
+ ? 'This spice blow names the territory, sector and base spice amount. The storm and card effects can change how much spice actually appears.'
+ : kind === 'worm'
+ ? 'A Shai-Hulud encounter can consume forces and spice in the territory identified by the preceding card in its spice discard pile. Resolve protection, Nexus and riding decisions through the table.'
+ : 'Sandtrout breaks existing alliances and waits for the next active drawn Shai-Hulud. It suppresses that encounter, then the immediate replacement determines whether a spice blow is doubled.';
+ return (
+
+
+ }
+ aria-label={`Inspect spice card: ${title}${context ? ` · ${context}` : ''}`}
+ >
+
+ Inspect spice card
+
+
+
+
+
+
+ Spice card inspection
+
+
+ }
+ >
+
+ Close
+
+
+
+
+
+
+ {kind === 'territory'
+ ? 'Spice blow'
+ : kind === 'worm'
+ ? 'Worm encounter'
+ : 'Sandtrout'}
+
+
+
+ {faceName}
+
+ {'territory' in card && (
+
+
+
Card amount
+
+ {card.amount}
+ spice
+
+
+
+
Sector
+
+ {card.sector}
+
+
+
+ )}
+ {'worm' in card && card.thumper && (
+
+ Thumper-created encounter
+
+ )}
+ {'worm' in card && card.suppressed && (
+
+ Suppressed by Sandtrout
+
+ )}
+
+ Spice · gameplay guide
+
+
+
+ {context && (
+
+ {context}
+
+ )}
+
+ {description}
+
+
+ Inspection does not draw a card or resolve an effect. Close
+ this view to use the table’s phase controls.
+
+ {kind === 'territory' ? (
+
+ Spice cannot appear in the storm’s sector. A fresh blow
+ remains open for Harvester before acceptance. The amount on
+ this face is the card’s base value, not the total spice
+ currently on the board.
+
+ ) : kind === 'worm' ? (
+
+ First-turn drawn worms are ignored and later shuffled back.
+ A worm suppressed by Sandtrout does not devour the preceding
+ territory or create its own Nexus or Fremen ride. A normal
+ encounter follows the table’s worm and alliance decisions.
+
+ ) : (
+ <>
+
+ Territories drawn before the next active Shai-Hulud
+ receive their usual spice. Sandtrout remains pending until
+ that worm is suppressed, even across spice blows or turns.
+
+
+ If the immediate replacement is a territory, double its
+ spice; Harvester may enhance that fresh blow again. If the
+ replacement is another Shai-Hulud, resolve it normally and
+ do not double a later territory. Spice still cannot appear
+ in the storm.
+
+ >
+ )}
+
+ {PHASE_HELP[1]}
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/stronghold-cards.tsx b/components/stronghold-cards.tsx
new file mode 100644
index 00000000..2f6da46c
--- /dev/null
+++ b/components/stronghold-cards.tsx
@@ -0,0 +1,219 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import Image from 'next/image';
+import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
+import { Button } from './ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogDescription,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+} from './ui/dialog';
+import type { Action } from '@/game/engine';
+import {
+ STRONGHOLD_CARDS,
+ type StrongholdId,
+ type StrongholdState,
+} from '@/game/stronghold-cards';
+
+type StrongholdDefinition = (typeof STRONGHOLD_CARDS)[number];
+
+/** Original, bundled artwork; rules remain independent selectable text. */
+function StrongholdIllustration({ id }: { id: StrongholdId }) {
+ return (
+
+ );
+}
+
+function StrongholdFace({
+ card,
+ ownership,
+ children,
+ enlarged = false,
+}: {
+ enlarged?: boolean;
+ card: StrongholdDefinition;
+ ownership?: string;
+ children?: ReactNode;
+}) {
+ return (
+
+
+
+
+ {card.name}
+
+
+
+
+
+
+
+
+ {ownership && (
+
+ {ownership}
+
+ )}
+ {card.gameplay.map((paragraph) => (
+
+ {paragraph}
+
+ ))}
+ {!enlarged && (
+
+
+ }
+ >
+ Inspect {card.name}
+
+
+
+
+
+
+ {card.name}
+
+
+ }
+ >
+ Close
+
+
+
+ Full Stronghold Card with its public ownership and gameplay
+ text.
+
+
+
+
+
+
+
+ )}
+ {children}
+
+ CHOAM & Richese · Advanced Stronghold
+
+
+
+
+ );
+}
+
+export function StrongholdCardGallery({
+ state,
+ players,
+}: {
+ state: StrongholdState;
+ players: { id: string; name: string }[];
+}) {
+ return (
+
+
+
+ Advanced Stronghold cards
+
+
+ CHOAM & Richese · Optional module
+ {state.claimedTurn > 0
+ ? ` · Last claim: turn ${state.claimedTurn}`
+ : ' · Initial claims pending'}
+
+
+
+ {STRONGHOLD_CARDS.map((card) => {
+ const owner = state.owners[card.id];
+ const name = players.find((player) => player.id === owner)?.name;
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+export function StrongholdCopyChoice({
+ event,
+ choices,
+ act,
+ busy,
+}: {
+ event: string;
+ choices: StrongholdId[];
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const available = STRONGHOLD_CARDS.filter((card) =>
+ choices.includes(card.id),
+ );
+ return (
+
+
+ Choose the controlled stronghold advantage to copy before making battle
+ plans.
+
+
+ {available.map((card) => (
+
+
+ act({ type: 'decision', event, stronghold: card.id })
+ }
+ >
+ Copy {card.name}
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/truthtrance-card-count.tsx b/components/truthtrance-card-count.tsx
new file mode 100644
index 00000000..8d082cb5
--- /dev/null
+++ b/components/truthtrance-card-count.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { useId } from 'react';
+import { Input } from '@/components/ui/input';
+import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
+import type { CardCountFact } from '@/game/truthtrance-card-count';
+
+export function CardCountFields({
+ value,
+ onChange,
+}: {
+ value: CardCountFact;
+ onChange: (fact: CardCountFact) => void;
+}) {
+ const id = useId();
+ const invalid = !Number.isSafeInteger(value.value) || value.value < 0;
+ return (
+ <>
+
+ Card name
+ onChange({ ...value, name: event.target.value })}
+ >
+ {TRUTH_CARD_NAMES.map((name) => (
+ {name}
+ ))}
+
+
+
+ Compare card count
+
+ onChange({
+ ...value,
+ compare: event.target.value as CardCountFact['compare'],
+ })
+ }
+ >
+ Exactly
+ At least
+ At most
+
+
+
+ Number of copies
+
+ onChange({ ...value, value: event.currentTarget.valueAsNumber })
+ }
+ />
+
+
+ Counts copies of this exact named card in the player’s hand now. Cards
+ in the deck, discard pile, or a separate cache do not count. The answer
+ reveals only whether the comparison is true.
+
+ {invalid && (
+
+ {Number.isInteger(value.value) &&
+ value.value > Number.MAX_SAFE_INTEGER
+ ? 'That card count is too large.'
+ : 'Enter a whole card count of zero or more.'}
+
+ )}
+ >
+ );
+}
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
new file mode 100644
index 00000000..93c709be
--- /dev/null
+++ b/components/truthtrance.tsx
@@ -0,0 +1,701 @@
+'use client';
+import { TERRITORIES } from '@/game/board';
+import { BattleClaimFields } from './battle-promises';
+import { CardCountFields } from './truthtrance-card-count';
+import type { PlanClaim } from '@/game/battle-promises';
+import { useId, useState } from 'react';
+import Link from 'next/link';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
+import {
+ TRUTH_CARD_NAMES,
+ truthQuestionText,
+ type TruthFact,
+ type TruthQuestion,
+} from '@/game/truthtrance';
+import type { Action, GameView } from '@/game/engine';
+
+export function Truthtrance({
+ game: g,
+ act,
+ busy,
+}: {
+ game: GameView;
+ act: (a: Action) => void;
+ busy: boolean;
+}) {
+ const w = g.truthtrance!;
+ const me = g.players.find((p) => p.id === g.me)!;
+ const current = w.queue[0];
+ const [target, setTarget] = useState(
+ g.players.find((p) => p.id !== me.id)!.id,
+ );
+ const [kind, setKind] = useState<
+ 'fact' | 'freeform' | 'battlePlan' | 'shipment'
+ >('fact');
+ const [shipmentTerritory, setShipmentTerritory] = useState('carthag');
+ const [shipmentMinimum, setShipmentMinimum] = useState(6);
+ const invalidShipment =
+ !Number.isSafeInteger(shipmentMinimum) ||
+ shipmentMinimum < 1 ||
+ shipmentMinimum > 20;
+ const shipmentAvailable =
+ !g.advanced &&
+ !g.expansions.length &&
+ g.phase === 5 &&
+ g.active === target &&
+ !g.players.find((p) => p.id === target)?.shipped &&
+ !g.response &&
+ !g.decision &&
+ !g.phaseOpening;
+ const [planClauses, setPlanClauses] = useState([
+ { kind: 'dial', compare: 'gte', value: 1 },
+ { kind: 'weapon', name: null },
+ ]);
+ const [scope, setScope] = useState<'fact' | 'currentTurn'>('fact');
+ const [text, setText] = useState('');
+ const [join, setJoin] = useState<'single' | 'and' | 'or'>('single');
+ const [clauses, setClauses] = useState([
+ { kind: 'hand', name: 'Shield' },
+ { kind: 'hand', name: 'Snooper' },
+ ]);
+ const factId = useId();
+ const invalidSpice = (fact: TruthFact) =>
+ (fact.kind === 'spice' || fact.kind === 'handCount') &&
+ (!Number.isSafeInteger(fact.value) || fact.value < 0);
+ const invalidFact = clauses
+ .slice(0, join === 'single' ? 1 : 2)
+ .some(invalidSpice);
+ const leaderName = (id: string) =>
+ id === CHEAP_HERO_TRAITOR
+ ? 'Cheap Hero / Heroine'
+ : (g.allLeaders.find((l) => l.id === id)?.name ?? id);
+ const name = (id: string) => g.players.find((p) => p.id === id)!.name;
+ const question: TruthQuestion =
+ kind === 'fact'
+ ? {
+ kind,
+ target,
+ fact: join === 'single' ? clauses[0] : { kind: join, terms: clauses },
+ }
+ : kind === 'battlePlan'
+ ? {
+ kind,
+ target,
+ territory: g.battle?.territory ?? '',
+ claim:
+ join === 'single'
+ ? planClauses[0]
+ : { kind: join, terms: planClauses },
+ }
+ : kind === 'shipment'
+ ? {
+ kind,
+ target,
+ territory: shipmentTerritory,
+ minimum: shipmentMinimum,
+ }
+ : { kind, target, text, scope };
+ const button = (label: string, action: Action, disabled = false) => (
+ act(action)}
+ >
+ {label}
+
+ );
+ return (
+
+ Truthtrance
+
+ Question rules · The table
+ resumes its previous decision after these questions.
+
+ {w.stage === 'priority' ? (
+ <>
+
+ A Truthtrance has been declared. Declare yours now or pass;
+ questions then follow storm order.
+
+
+ {g.players.map((p) => (
+
+ {p.name} ·{' '}
+ {w.queue.some((q) => q.player === p.id)
+ ? 'Declared'
+ : w.passed.includes(p.id)
+ ? 'Passed'
+ : 'Choosing'}
+
+ ))}
+
+ {!w.passed.includes(me.id) && (
+ <>
+ {me.hand
+ ?.filter((c) => c.effect === 'truthtrance')
+ .slice(0, 1)
+ .map((c) => (
+
+ {button('Declare my Truthtrance', {
+ type: 'card',
+ card: c.id,
+ })}
+
+ ))}
+ {(me.hand?.filter((c) => c.effect === 'truthtrance').length ??
+ 0) > 1 &&
+ button('Declare both Truthtrances', {
+ type: 'card',
+ card: me.hand!.find((c) => c.effect === 'truthtrance')!.id,
+ cards: me
+ .hand!.filter((c) => c.effect === 'truthtrance')
+ .slice(1)
+ .map((c) => c.id),
+ })}
+ {button('Pass Truthtrance priority', { type: 'truthPass' })}
+ >
+ )}
+ >
+ ) : (
+ <>
+
+ {name(current.player)} holds the question · {w.queue.length} pending
+
+ {w.question && (
+
+ {truthQuestionText(w.question, leaderName)}
+ Asked of {name(w.question.target)}
+
+ )}
+ {w.stage === 'answer' ? (
+ w.question!.target === me.id ? (
+ <>
+
+ Your answer is public. Answer truthfully about the game; a
+ promise can only bind decisions in this turn.
+
+ {w.question!.kind === 'shipment' ? (
+ <>
+
+ A definite answer binds your shipment from reserves this
+ turn. Available preparation includes your Ghola, Karama
+ and recoverable allied funding. Yes leaves the exact
+ count, sector and payment yours to choose; No allows a
+ different shipment or none.
+
+ {(g.truthShipmentAnswers ?? []).map((answer) => (
+
+ {button(
+ answer === 'unknown'
+ ? 'I don’t know yet'
+ : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`,
+ { type: 'truthAnswer', answer },
+ )}
+
+ ))}
+ >
+ ) : w.question!.kind === 'battlePlan' ? (
+ <>
+
+ Choose an answer you can honor in a legal plan, including
+ available Ghola preparation. Earlier Truthtrance answers,
+ Voice and revealed prescience elements apply. An answer
+ about a sealed plan is already fixed.
+
+ {(g.truthBattleAnswers ?? []).map((answer) => (
+
+ {button(
+ answer === 'unknown'
+ ? 'I don’t know yet'
+ : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`,
+ { type: 'truthAnswer', answer },
+ )}
+
+ ))}
+
+ A definite answer constrains your plan. Other plan
+ elements remain yours to choose.
+
+ >
+ ) : w.question!.kind === 'fact' ? (
+ <>
+
+ Your current game information establishes:{' '}
+
+ {g.truthAnswer === 'unknown'
+ ? 'I don’t know yet'
+ : g.truthAnswer === 'yes'
+ ? 'Yes'
+ : 'No'}
+
+ . Only you see this until you answer.
+
+ {button('Publish my answer', {
+ type: 'truthAnswer',
+ answer: g.truthAnswer,
+ })}
+ >
+ ) : (
+ <>
+ {button('Answer Yes', {
+ type: 'truthAnswer',
+ answer: 'yes',
+ })}
+ {button('Answer No', { type: 'truthAnswer', answer: 'no' })}
+ {button('I don’t know', {
+ type: 'truthAnswer',
+ answer: 'unknown',
+ })}
+
+ Use “I don’t know” for something you cannot know or
+ decide. The holder can ask a different question or save
+ the card.
+
+ >
+ )}
+ >
+ ) : (
+ Waiting for {name(w.question!.target)} to answer.
+ )
+ ) : current.player !== me.id ? (
+
+ Waiting for {name(current.player)} to{' '}
+ {w.stage === 'unknown'
+ ? 'ask again or save the card'
+ : 'ask a question'}
+ .
+
+ ) : (
+ <>
+ {w.stage === 'unknown' && (
+ <>
+
+ The answer could not be known. Ask a different question or
+ keep Truthtrance for later.
+
+ {button('Save card for later', { type: 'truthSave' })}
+ >
+ )}
+
+ Ask which player?
+ setTarget(e.target.value)}
+ >
+ {g.players
+ .filter((p) => p.id !== me.id)
+ .map((p) => (
+
+ {p.name}
+ {p.bot ? ' · AI' : ''}
+
+ ))}
+
+
+
+ Question type
+ setKind(e.target.value as typeof kind)}
+ >
+
+ Verified cards, traitors or spice
+
+
+ Bind a shipment from reserves
+
+ Write a game question
+ {g.battle && (
+
+ Bind a battle-plan choice
+
+ )}
+
+
+ {kind === 'shipment' ? (
+ <>
+
+ Shipment destination
+ setShipmentTerritory(e.target.value)}
+ >
+ {TERRITORIES.map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+
+ Minimum physical forces
+
+ setShipmentMinimum(e.currentTarget.valueAsNumber)
+ }
+ />
+
+
+ Includes Fremen reinforcements and Guild transport from
+ southern reserves. Ground movement and transport of forces
+ already on the board do not count.
+
+ {!shipmentAvailable && (
+
+ Automatic enforcement currently supports the active
+ player’s unused shipment in the Basic game without
+ expansions. Select that player and finish pending
+ decisions first. Earlier questions and other rules
+ configurations remain unfinished.
+
+ )}
+
+ {invalidShipment
+ ? 'Enter a whole minimum of one to twenty forces.'
+ : truthQuestionText(question, leaderName)}
+
+ >
+ ) : kind === 'battlePlan' ? (
+ <>
+
+ Combine conditions
+ setJoin(e.target.value as typeof join)}
+ >
+ One condition
+ Both · AND
+ Either · OR
+
+
+ {planClauses
+ .slice(0, join === 'single' ? 1 : 2)
+ .map((c, index) => (
+
+ Condition {index + 1}
+
+ setPlanClauses(
+ planClauses.map((old, n) =>
+ n === index ? next : old,
+ ),
+ )
+ }
+ />
+
+ ))}
+
+ {truthQuestionText(question, leaderName)}
+
+ {g.battle &&
+ ![g.battle.attacker, g.battle.defender].includes(
+ target,
+ ) && Select one of the combatants for this question.
}
+ >
+ ) : kind === 'fact' ? (
+ <>
+
+ Combine facts
+ setJoin(e.target.value as typeof join)}
+ >
+ One fact
+ Both facts · AND
+ Either fact · OR
+
+
+ {clauses
+ .slice(0, join === 'single' ? 1 : 2)
+ .map((c, index) => (
+
+ Fact {index + 1}
+
+ Fact type
+
+ setClauses(
+ clauses.map((old, n) =>
+ n === index
+ ? e.target.value === 'hand'
+ ? { kind: 'hand', name: 'Shield' }
+ : e.target.value === 'handCount'
+ ? {
+ kind: 'handCount',
+ name: 'Shield',
+ compare: 'gte',
+ value: 2,
+ }
+ : e.target.value === 'spice'
+ ? {
+ kind: 'spice',
+ compare: 'gte',
+ value: 6,
+ }
+ : {
+ kind: 'traitor',
+ leader: g.allLeaders[0].id,
+ }
+ : old,
+ ),
+ )
+ }
+ >
+ Holds a named card
+
+ Number of a named card
+
+ Selected a traitor
+
+ Current personal spice
+
+
+
+ {c.kind === 'hand' ? (
+
+ Card name
+
+ setClauses(
+ clauses.map((old, n) =>
+ n === index
+ ? { kind: 'hand', name: e.target.value }
+ : old,
+ ),
+ )
+ }
+ >
+ {TRUTH_CARD_NAMES.map((name) => (
+ {name}
+ ))}
+
+
+ ) : c.kind === 'handCount' ? (
+
+ setClauses(
+ clauses.map((old, n) =>
+ n === index ? next : old,
+ ),
+ )
+ }
+ />
+ ) : c.kind === 'spice' ? (
+ <>
+
+ Compare personal spice
+
+ setClauses(
+ clauses.map((old, n) =>
+ n === index
+ ? {
+ ...c,
+ compare: e.target
+ .value as typeof c.compare,
+ }
+ : old,
+ ),
+ )
+ }
+ >
+ Exactly
+ At least
+ At most
+
+
+
+ Spice amount
+
+ setClauses(
+ clauses.map((old, n) =>
+ n === index
+ ? {
+ ...c,
+ value:
+ e.currentTarget.valueAsNumber,
+ }
+ : old,
+ ),
+ )
+ }
+ />
+
+
+ Counts spice the player personally holds now.
+ Excludes pledged aid and incoming payments. This
+ asks about the current balance; it does not
+ promise future holdings or spending.
+
+ {invalidSpice(c) && (
+
+ {Number.isInteger(c.value) &&
+ c.value > Number.MAX_SAFE_INTEGER
+ ? 'That spice amount is too large.'
+ : 'Enter a whole spice amount of zero or more.'}
+
+ )}
+ >
+ ) : (
+ c.kind === 'traitor' && (
+
+ Leader
+
+ setClauses(
+ clauses.map((old, n) =>
+ n === index
+ ? {
+ kind: 'traitor',
+ leader: e.target.value,
+ }
+ : old,
+ ),
+ )
+ }
+ >
+ {g.allLeaders.map((l) => (
+
+ {l.name}
+
+ ))}
+
+ Cheap Hero / Heroine
+
+
+
+ )
+ )}
+
+ ))}
+
+ {invalidFact
+ ? 'Enter a valid whole amount before asking this question.'
+ : truthQuestionText(question, leaderName)}
+
+ >
+ ) : (
+ <>
+
+ Question concerns
+ setScope(e.target.value as typeof scope)}
+ >
+ A game fact
+
+ A promise during turn {g.turn}
+
+
+
+
+ One yes/no question
+
+
+ Freeform answers rely on the players. Automatic checks for
+ arbitrary promises are unfinished. AI can answer structured
+ fact, supported shipment and battle-plan questions; it
+ cannot yet interpret freeform questions.
+
+ >
+ )}
+ {button(
+ 'Ask publicly',
+ { type: 'truthAsk', question },
+ (kind === 'shipment' &&
+ (invalidShipment || !shipmentAvailable)) ||
+ (kind === 'freeform' && !text.trim()) ||
+ (kind === 'fact' && invalidFact) ||
+ (kind === 'battlePlan' &&
+ (!g.battle ||
+ ![g.battle.attacker, g.battle.defender].includes(
+ target,
+ ))),
+ )}
+ >
+ )}
+ >
+ )}
+
+ );
+}
+export function TruthHistory({ game: g }: { game: GameView }) {
+ const records = g.truthHistory.filter((r) => r.turn === g.turn);
+ if (!records.length) return null;
+ const name = (id: string) => g.players.find((p) => p.id === id)?.name ?? id;
+ return (
+
+ Truthtrance answers · Turn {g.turn}
+
+ Structured battle-plan and supported reserve-shipment answers are
+ enforced. Current-turn freeform promises remain binding when possible,
+ but their automatic enforcement is unfinished.
+
+
+ {records.map((r, i) => (
+
+
+ {name(r.asker)} → {name(r.question.target)}:{' '}
+ {truthQuestionText(r.question, (id) =>
+ id === CHEAP_HERO_TRAITOR
+ ? 'Cheap Hero / Heroine'
+ : (g.allLeaders.find((l) => l.id === id)?.name ?? id),
+ )}
+
+
+ {r.answer === 'unknown'
+ ? 'I don’t know'
+ : r.answer === 'yes'
+ ? 'Yes'
+ : 'No'}
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 00000000..9daf7bf3
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -0,0 +1,78 @@
+import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion';
+
+import { cn } from '@/lib/utils';
+import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
+
+function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
+ return (
+
+ );
+}
+
+function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
+ return (
+
+ );
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: AccordionPrimitive.Trigger.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: AccordionPrimitive.Panel.Props) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..46d9eb7f
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,187 @@
+'use client';
+
+import * as React from 'react';
+import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog';
+
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+ return ;
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return (
+
+ );
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return (
+
+ );
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ size = 'default',
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: 'default' | 'sm';
+}) {
+ return (
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ variant = 'outline',
+ size = 'default',
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, 'variant' | 'size'>) {
+ return (
+ }
+ {...props}
+ />
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
new file mode 100644
index 00000000..abe04d37
--- /dev/null
+++ b/components/ui/alert.tsx
@@ -0,0 +1,76 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/lib/utils';
+
+const alertVariants = cva(
+ "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+ svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction };
diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx
new file mode 100644
index 00000000..95d97f8e
--- /dev/null
+++ b/components/ui/aspect-ratio.tsx
@@ -0,0 +1,22 @@
+import { cn } from '@/lib/utils';
+
+function AspectRatio({
+ ratio,
+ className,
+ ...props
+}: React.ComponentProps<'div'> & { ratio: number }) {
+ return (
+
+ );
+}
+
+export { AspectRatio };
diff --git a/components/ui/attachment.tsx b/components/ui/attachment.tsx
new file mode 100644
index 00000000..108ab4da
--- /dev/null
+++ b/components/ui/attachment.tsx
@@ -0,0 +1,207 @@
+import * as React from 'react';
+import { mergeProps } from '@base-ui/react/merge-props';
+import { useRender } from '@base-ui/react/use-render';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+
+const attachmentVariants = cva(
+ 'rounded-xl w-fit focus-within:ring-1 focus-within:ring-ring/50 group/attachment relative flex max-w-full min-w-0 shrink-0 flex-wrap border bg-card text-card-foreground transition-colors has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed',
+ {
+ variants: {
+ size: {
+ default:
+ 'gap-2 has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2 text-sm',
+ sm: 'gap-2.5 has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5 text-xs',
+ xs: 'gap-1.5 has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1 text-xs rounded-lg',
+ },
+ orientation: {
+ horizontal: 'min-w-40 items-center',
+ vertical: 'w-24 has-data-[slot=attachment-content]:w-30 flex-col',
+ },
+ },
+ },
+);
+
+function Attachment({
+ className,
+ state = 'done',
+ size = 'default',
+ orientation = 'horizontal',
+ ...props
+}: React.ComponentProps<'div'> &
+ VariantProps
& {
+ state?: 'idle' | 'uploading' | 'processing' | 'error' | 'done';
+ }) {
+ return (
+
+ );
+}
+
+const attachmentMediaVariants = cva(
+ "bg-muted text-foreground w-10 rounded-lg group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md [&_svg:not([class*='size-'])]:size-4 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5 group-data-[orientation=vertical]/attachment:w-full group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! relative flex aspect-square shrink-0 items-center justify-center overflow-hidden group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive [&_svg]:pointer-events-none",
+ {
+ variants: {
+ variant: {
+ icon: '',
+ image:
+ 'opacity-60 group-data-[state=idle]/attachment:opacity-100 group-data-[state=done]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover',
+ },
+ },
+ defaultVariants: {
+ variant: 'icon',
+ },
+ },
+);
+
+function AttachmentMedia({
+ className,
+ variant = 'icon',
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ );
+}
+
+function AttachmentContent({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AttachmentTitle({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+function AttachmentDescription({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+function AttachmentActions({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AttachmentAction({
+ className,
+ variant,
+ size = 'icon-xs',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AttachmentTrigger({
+ className,
+ render,
+ type,
+ ...props
+}: useRender.ComponentProps<'button'>) {
+ return useRender({
+ defaultTagName: 'button',
+ props: mergeProps<'button'>(
+ {
+ type: render ? type : (type ?? 'button'),
+ className: cn('absolute inset-0 z-10 outline-none', className),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: 'attachment-trigger',
+ },
+ });
+}
+
+function AttachmentGroup({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+export {
+ Attachment,
+ AttachmentGroup,
+ AttachmentMedia,
+ AttachmentContent,
+ AttachmentTitle,
+ AttachmentDescription,
+ AttachmentActions,
+ AttachmentAction,
+ AttachmentTrigger,
+};
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 00000000..789a0141
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,109 @@
+'use client';
+
+import * as React from 'react';
+import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar';
+
+import { cn } from '@/lib/utils';
+
+function Avatar({
+ className,
+ size = 'default',
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: 'default' | 'sm' | 'lg';
+}) {
+ return (
+
+ );
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+
+ );
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+ svg]:hidden',
+ 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
+ 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+};
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 00000000..8e59eb1b
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from '@base-ui/react/merge-props';
+import { useRender } from '@base-ui/react/use-render';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/lib/utils';
+
+const badgeVariants = cva(
+ 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
+ secondary:
+ 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
+ destructive:
+ 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
+ outline:
+ 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
+ ghost:
+ 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant = 'default',
+ render,
+ ...props
+}: useRender.ComponentProps<'span'> & VariantProps
) {
+ return useRender({
+ defaultTagName: 'span',
+ props: mergeProps<'span'>(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: 'badge',
+ variant,
+ },
+ });
+}
+
+export { Badge, badgeVariants };
diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx
new file mode 100644
index 00000000..6f51e3fa
--- /dev/null
+++ b/components/ui/breadcrumb.tsx
@@ -0,0 +1,122 @@
+import * as React from 'react';
+import { mergeProps } from '@base-ui/react/merge-props';
+import { useRender } from '@base-ui/react/use-render';
+
+import { cn } from '@/lib/utils';
+import { ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
+
+function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
+ return (
+
+ );
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<'a'>) {
+ return useRender({
+ defaultTagName: 'a',
+ props: mergeProps<'a'>(
+ {
+ className: cn('hover:text-foreground transition-colors', className),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: 'breadcrumb-link',
+ },
+ });
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<'li'>) {
+ return (
+ svg]:size-3.5', className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+ svg]:size-4 flex items-center justify-center',
+ className,
+ )}
+ {...props}
+ >
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/components/ui/bubble.tsx b/components/ui/bubble.tsx
new file mode 100644
index 00000000..61f20300
--- /dev/null
+++ b/components/ui/bubble.tsx
@@ -0,0 +1,128 @@
+import * as React from 'react';
+import { mergeProps } from '@base-ui/react/merge-props';
+import { useRender } from '@base-ui/react/use-render';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/lib/utils';
+
+function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+const bubbleVariants = cva(
+ 'gap-1 data-[align=end]:self-end max-w-[80%] data-[variant=ghost]:max-w-full group-data-[align=end]/message:self-end group/bubble relative flex w-fit min-w-0 flex-col',
+ {
+ variants: {
+ variant: {
+ default:
+ '*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
+ secondary:
+ '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
+ muted:
+ '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
+ tinted:
+ '*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
+ outline:
+ '*:data-[slot=bubble-content]:bg-background *:data-[slot=bubble-content]:border-border [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
+ ghost:
+ '*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none',
+ destructive:
+ '*:data-[slot=bubble-content]:bg-destructive/10 dark:*:data-[slot=bubble-content]:bg-destructive/20 *:data-[slot=bubble-content]:text-destructive [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+function Bubble({
+ variant = 'default',
+ align = 'start',
+ className,
+ ...props
+}: React.ComponentProps<'div'> &
+ VariantProps & {
+ align?: 'start' | 'end';
+ }) {
+ return (
+
+ );
+}
+
+function BubbleContent({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<'div'>) {
+ return useRender({
+ defaultTagName: 'div',
+ props: mergeProps<'div'>(
+ {
+ className: cn(
+ 'rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50 group-data-[align=end]/bubble:self-end w-fit max-w-full min-w-0 overflow-hidden wrap-break-word [button]:text-left [button,a]:transition-colors',
+ className,
+ ),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: 'bubble-content',
+ },
+ });
+}
+
+const bubbleReactionsVariants = cva(
+ 'rounded-full ring-3 ring-card bg-muted shrink-0 gap-1 px-1.5 py-0.5 has-[button]:p-0 text-sm absolute z-10 flex w-fit items-center justify-center',
+ {
+ variants: {
+ side: {
+ top: 'top-0 -translate-y-3/4',
+ bottom: 'bottom-0 translate-y-3/4',
+ },
+ align: {
+ start: 'left-3',
+ end: 'right-3',
+ },
+ },
+ defaultVariants: {
+ side: 'bottom',
+ align: 'end',
+ },
+ },
+);
+
+function BubbleReactions({
+ side = 'bottom',
+ align = 'end',
+ className,
+ ...props
+}: React.ComponentProps<'div'> & {
+ align?: 'start' | 'end';
+ side?: 'top' | 'bottom';
+}) {
+ return (
+
+ );
+}
+
+export { BubbleGroup, Bubble, BubbleContent, BubbleReactions };
diff --git a/components/ui/button-group.tsx b/components/ui/button-group.tsx
new file mode 100644
index 00000000..8f8c2d29
--- /dev/null
+++ b/components/ui/button-group.tsx
@@ -0,0 +1,87 @@
+import { mergeProps } from '@base-ui/react/merge-props';
+import { useRender } from '@base-ui/react/use-render';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/lib/utils';
+import { Separator } from '@/components/ui/separator';
+
+const buttonGroupVariants = cva(
+ "has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! *:data-slot:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
+ vertical:
+ '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! flex-col *:data-slot:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0',
+ },
+ },
+ defaultVariants: {
+ orientation: 'horizontal',
+ },
+ },
+);
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ );
+}
+
+function ButtonGroupText({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<'div'>) {
+ return useRender({
+ defaultTagName: 'div',
+ props: mergeProps<'div'>(
+ {
+ className: cn(
+ "bg-muted gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
+ className,
+ ),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: 'button-group-text',
+ },
+ });
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+};
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 00000000..7a8a32ce
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import { Button as ButtonPrimitive } from '@base-ui/react/button';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/lib/utils';
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/80',
+ outline:
+ 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
+ ghost:
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
+ destructive:
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default:
+ 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
+ icon: 'size-8',
+ 'icon-xs':
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ 'icon-sm':
+ 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
+ 'icon-lg': 'size-9',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+function Button({
+ className,
+ variant = 'default',
+ size = 'default',
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx
new file mode 100644
index 00000000..5d1cb820
--- /dev/null
+++ b/components/ui/calendar.tsx
@@ -0,0 +1,231 @@
+'use client';
+
+import * as React from 'react';
+import {
+ DayPicker,
+ getDefaultClassNames,
+ type DayButton,
+ type Locale,
+} from 'react-day-picker';
+
+import { cn } from '@/lib/utils';
+import { Button, buttonVariants } from '@/components/ui/button';
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ ChevronDownIcon,
+} from 'lucide-react';
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = 'label',
+ buttonVariant = 'ghost',
+ locale,
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps['variant'];
+}) {
+ const defaultClassNames = getDefaultClassNames();
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className,
+ )}
+ captionLayout={captionLayout}
+ locale={locale}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString(locale?.code, { month: 'short' }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn('w-fit', defaultClassNames.root),
+ months: cn(
+ 'relative flex flex-col gap-4 md:flex-row',
+ defaultClassNames.months,
+ ),
+ month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
+ nav: cn(
+ 'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
+ defaultClassNames.nav,
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
+ defaultClassNames.button_previous,
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
+ defaultClassNames.button_next,
+ ),
+ month_caption: cn(
+ 'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
+ defaultClassNames.month_caption,
+ ),
+ dropdowns: cn(
+ 'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
+ defaultClassNames.dropdowns,
+ ),
+ dropdown_root: cn(
+ 'cn-calendar-dropdown-root relative rounded-(--cell-radius)',
+ defaultClassNames.dropdown_root,
+ ),
+ dropdown: cn(
+ 'absolute inset-0 bg-popover opacity-0',
+ defaultClassNames.dropdown,
+ ),
+ caption_label: cn(
+ 'font-medium select-none',
+ captionLayout === 'label'
+ ? 'cn-calendar-caption text-sm'
+ : 'cn-calendar-caption-label flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
+ defaultClassNames.caption_label,
+ ),
+ month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
+ weekdays: cn('flex', defaultClassNames.weekdays),
+ weekday: cn(
+ 'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
+ defaultClassNames.weekday,
+ ),
+ week: cn('mt-2 flex w-full', defaultClassNames.week),
+ week_number_header: cn(
+ 'w-(--cell-size) select-none',
+ defaultClassNames.week_number_header,
+ ),
+ week_number: cn(
+ 'text-[0.8rem] text-muted-foreground select-none',
+ defaultClassNames.week_number,
+ ),
+ day: cn(
+ 'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
+ props.showWeekNumber
+ ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
+ : '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
+ defaultClassNames.day,
+ ),
+ range_start: cn(
+ 'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
+ defaultClassNames.range_start,
+ ),
+ range_middle: cn('rounded-none', defaultClassNames.range_middle),
+ range_end: cn(
+ 'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
+ defaultClassNames.range_end,
+ ),
+ today: cn(
+ 'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
+ defaultClassNames.today,
+ ),
+ outside: cn(
+ 'text-muted-foreground aria-selected:text-muted-foreground',
+ defaultClassNames.outside,
+ ),
+ disabled: cn(
+ 'text-muted-foreground opacity-50',
+ defaultClassNames.disabled,
+ ),
+ hidden: cn('invisible', defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ );
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === 'left') {
+ return (
+
+ );
+ }
+
+ if (orientation === 'right') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+ DayButton: ({ ...props }) => (
+
+ ),
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+
+ );
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ );
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ locale,
+ ...props
+}: React.ComponentProps & { locale?: Partial }) {
+ const defaultClassNames = getDefaultClassNames();
+
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus();
+ }, [modifiers.focused]);
+
+ return (
+ span]:text-xs [&>span]:opacity-70',
+ defaultClassNames.day,
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Calendar, CalendarDayButton };
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 00000000..ae29aa24
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+function Card({
+ className,
+ size = 'default',
+ ...props
+}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
+ return (
+ img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx
new file mode 100644
index 00000000..f7e33307
--- /dev/null
+++ b/components/ui/carousel.tsx
@@ -0,0 +1,242 @@
+'use client';
+
+import * as React from 'react';
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from 'embla-carousel-react';
+
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters
;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: 'horizontal' | 'vertical';
+ setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error('useCarousel must be used within a ');
+ }
+
+ return context;
+}
+
+function Carousel({
+ orientation = 'horizontal',
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<'div'> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === 'horizontal' ? 'x' : 'y',
+ },
+ plugins,
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return;
+ setCanScrollPrev(api.canScrollPrev());
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === 'ArrowRight') {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext],
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) return;
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) return;
+ onSelect(api);
+ api.on('reInit', onSelect);
+ api.on('select', onSelect);
+
+ return () => {
+ api?.off('select', onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselPrevious({
+ className,
+ variant = 'outline',
+ size = 'icon-sm',
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+
+ Previous slide
+
+ );
+}
+
+function CarouselNext({
+ className,
+ variant = 'outline',
+ size = 'icon-sm',
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+
+ Next slide
+
+ );
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+ useCarousel,
+};
diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx
new file mode 100644
index 00000000..0680cf0e
--- /dev/null
+++ b/components/ui/chart.tsx
@@ -0,0 +1,373 @@
+'use client';
+
+import * as React from 'react';
+import * as RechartsPrimitive from 'recharts';
+import type { TooltipValueType } from 'recharts';
+
+import { cn } from '@/lib/utils';
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: '', dark: '.dark' } as const;
+
+const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
+type TooltipNameType = number | string;
+
+export type ChartConfig = Record<
+ string,
+ {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+>;
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error('useChart must be used within a ');
+ }
+
+ return context;
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ initialDimension = INITIAL_DIMENSION,
+ ...props
+}: React.ComponentProps<'div'> & {
+ config: ChartConfig;
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >['children'];
+ initialDimension?: {
+ width: number;
+ height: number;
+ };
+}) {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme ?? config.color,
+ );
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+