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. +

+ +
+ )} + restoredSeat(view)} + /> +
+ {(notice || connection || needsReconcile || botRecovery) && ( + + {notice && {notice}} + {connection && {connection}} + {needsReconcile && ( + Actions paused until the current table is confirmed. + )} + {(connection || needsReconcile) && ( + + )} + {botRecovery && ( + <> + + AI progress was not confirmed. Check the table before + resuming. + + + + )} + + + )} + + ); + 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. +

+ + + + )} +
+
+ ); + 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 && ( + + )} +

+ 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. +

+ + +

+ A previously saved seat recovery kit can still be used. This keeps + the room-entry proof; the recovered seat may be different. +

+ + {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 ( +
+
+
+ DUNEARRAKIS TABLE +
+
+ Multiplayer table{' '} + + + Rulebook + +
+
+
+ +
+
+ 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 + + + 20Forces + + + {f.revival}Free revival + +
+
+
+ THE SPICE MUST FLOW + CLASSIC / 2019 +
+
+ +
+
+ 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. +

+ + + + + + + + + + {[ + [ + '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]) => ( + + + + + + ))} + +
AreaCurrent implementationVerification remaining
{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. +

+ + {rows.map((s) => ( +
+ + Sector {splitLocation(s.key).sector} · {s.forces} physical forces + available + + + {s.elites > 0 && ( + + )} + {s.key === target && ( +

+ Forces already in the destination sector stay in place. +

+ )} +
+ ))} + {source.marker && ( +
+ +

+ 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?.advisors && ( +

This group arrives as advisors.

+ )} + {destination?.canFight && ( + + )} +

+ Selected: {total} physical forces + {includesMarker ? ' and one concealed No-Field' : ''}. +

+ {reason && ( +

+ {reason} +

+ )} + +
+ ); +} 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. +

+ + {shipment.eliteReserves > 0 && ( + + )} + + + {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} +

+ )} + + +
+ ); +} 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 && 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. +

+ + + + + + ); + 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) => ( + + ))} + + + ); + 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)}. +

+ )} + + {stoneReason &&

{stoneReason}

} + + + ); +} 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 ( + <> + + {(c.kind === 'dial' || c.kind === 'support') && ( + <> + + + + )} + {(c.kind === 'weapon' || c.kind === 'defense') && ( + + )} + {c.kind === 'leader' && ( + + )} + {c.kind === 'kwisatz' && ( + + )} + + ); +} +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.truthPromises.map((p, i) => ( +
  • +

    + {g.players.find((other) => other.id === p.player)?.name}:{' '} + {p.answer ? 'Yes' : 'No'} to “ + {planClaimText(p.claim, name)}” + {p.released ? ' · No longer possible; released' : ''}. +

    +
  • + ))} +
+ {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] && ( + + )} +
+ )} + {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. +

+ +
+ )} +
+ ); +} 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 ( +
+ +
+
+ onChange(Number(event.target.value))} + /> + +
+ 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}`} + > + + + + +
+ + Card inspection + + + } + > + +
+
+
+
+
+ + {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. +

+ )} +
+ + +
+
+ ); +} + +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 && ( + <> + + + + ) + )} + {card && ( + + )} + {!card && d &&

No available Baliset play.

} + {d && ( + + )} +
+ ); +} 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. +

+ )} + + + {deciding && ( + + )} +
+ ); +} 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 ( +
+

CHOAM special Karama

+

+ Once per game, spend Karama and discard other cards for three spice + each. Committed battle cards are excluded. The activating Karama earns + no spice. +

+ + {choices.map((c) => ( + + ))} + +
+ ); +} 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 && ( + <> + + + + )} + {(!card || !options.length) && ( +

No available Trip to Gamont play.

+ )} + {g.decision?.kind === 'choamMentat' && ( + + )} +
+ ); +} 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) => ( + + ))} + + + ); + 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. +

+ + + + ); + 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) => ( + + ))} + {!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) => ( + + ))} + + )} + + + ); +} 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(', ')}. +

+ )} + + {card ? ( + + ) : ( +

+ No available Jubba Cloak play. Continue when ready to accept the + remaining losses. +

+ )} + +
+ ); +} 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 && ( + + )} + {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) => ( + + ))} + {reactive && ( + + )} +
+ ); +} 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 && ( + <> + + + {choice.cards.length > 0 && ( + <> + + + {given && ( +
+ + +
+ )} + + )} + + )} + {reason && ( +

+ {reason} +

+ )} + + {!!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 ( + + ); +} + +/** 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" + > + + + + +
+ + Shared leader inspection + + + } + > + +
+
+
+
+

+ 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 ( + + ); +} +/** 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. +

+ + {token && ( + <> +

+ {AMBASSADOR_REFERENCE[token.effect].gameplay} +

+ + + )} + + {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. +

+ )} +
+ + +
+

{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 && ( + + )} + + {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) => ( +
+ + {option.blocked && ( +

{option.blocked}

+ )} +
+ ))} + + )} + + + ) : 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}

+ )} + +
+ ); + })} + + ) : 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 }) => ( +
+ + + + {blocked &&

{blocked}

} +
+ ))} +
+ + + )} +

{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. + + )} +
{ + e.preventDefault(); + if (busy || !valid) return; + send({ + type: 'decision', + event, + allocation: { kind: 'propose', ecazShare }, + }); + }} + > + +

+ {valid + ? `Proposed split: ${ecazShare} for ${ecazName}, ${lot.amount - ecazShare} for ${allyName}.` + : `Enter a whole number from 0 to ${lot.amount}.`} +

+
+ + {offer && ( + + )} +
+
+

+ Equal fallback: {equalEcaz} spice for {ecazName}, {equalAlly} for{' '} + {allyName}. The ally receives any odd remainder. +

+ +
+ ); +} 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 ( + + ); +} 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]) => ( + + ), + )} + +

+ {total} / {maximum} replacement forces selected +

+ + + ) : ( +

You have no unrevealed Face Dancer matching the winning leader.

+ )} + + + ); +} 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 ( + + ); + }; + const destination = ( + <> + + + + ); + const amountInput = ( + <> + + {me.elites && ( + + )} + + ); + const sourceInput = ( + + ); + return ( +
+
+ +
+ + + {g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / 10`} + +
+
+
+ + {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' && ( + + )} + {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.

+
+ +
+ )} + + + + + + +
+ +
+
+ + + Storm · Sector {g.storm} + +
+ + +
+
+
+ + + + + + + + + + + + + {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.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'} +

+ )} + +
+ + {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. +

+ + {(me.hand?.filter( + (other) => other.effect === 'truthtrance', + ).length ?? 0) > 1 && ( + + )} + + )} + {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' && ( + <> + + {me.elites && ( + + )} + + + )} + {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. +

+ + + + {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. +

+ + + {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. +

+ + {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' && ( + + )} + {canUseAsKarama(g.advanced, me.faction, c) && + g.phase === 5 && + g.active && + !g.players.find((p) => p.id === g.active)?.shipped && ( + + )} + {c.effect !== 'truthtrance' && + (c.kind === 'special' || + canUseAsKarama(g.advanced, me.faction, c)) && ( + + )} +
+ ); + })} + {!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) => ( +
  1. + {String(e.seq).padStart(3, '0')} + {e.text.replace( + /used Karama to cancel fremenMovement\.$/, + 'used Karama to cancel Fremen movement.', + )} +
  2. + ))} +
+ ) : ( +
+

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]) => ( + + ))} +

Cyborgs to retain

+ {Object.entries(d.losses).map(([key, max]) => ( + + ))} +

+ {total(sources)} suboids for {total(recover)} cyborgs +

+ + + + ); +} 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 && } + + + + ); + 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' && ( + + )} +
+ {cards?.map((c) => ( +
+

+ {c.name} +

+ + +
+ ))} +
+ {kind === 'ixTechnology' && ( + + )} + + ); +} 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' : ''}.`} +

+ +
+ ); + }) + ) : ( +

+ {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 ( + + ); +} + +/** 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}`} + > + + + + +
+ + {label} inspection + + + } + > + +
+
+
+
+

+ {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) => ( +
  1. + {label(key)} + {g.spice[key] ? ` · ${g.spice[key]} spice` : ''} +
  2. + ))} +
+

+ {mobileRouteDistance(route)} / {max} territories ·{' '} + {collect ? preview : 0} spice to collect +

+ + )} + + {!placement && ( + <> + + + {!valid && route.length > 1 && ( +

Finish in a non-stronghold territory.

+ )} + + )} + + {!placement && !card && ( + + )} +
+ ); +} 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) => ( +
+ +

{battleCardLabel(card.kind)}

+ + +
+ ))} +
+ {!cards.length && ( +

+ No eligible card remains in your hand. Decline to continue. +

+ )} + + +
+ ); +} 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 ( + + ); +} + +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} + + + + + + + +
+ + Moritani · Terror + + + } + > + +
+
+
+
+

+ {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 ( +
+

Place or relocate Terror

+

+ 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. +

+
+ + + + + + {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.'} + + + +
+
+ ); +} + +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 ( +
+

Enemy of My Enemy

+

+ {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. +

+ ); + })} +
+ + +
+
+ ); + } + + 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 ( +
+

+ {definition.name} · Terror reaction +

+

+ {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. +

+ +
+ ) : entry.allianceBlockedReason ? ( +

+ Enemy of My Enemy: {entry.allianceBlockedReason} +

+ ) : null} + {entry.canReveal ? ( + + ) : ( +

+ {entry.revealBlocked ?? + `${definition.name} is still under implementation.`}{' '} + Leave the token hidden to continue this arrival. +

+ )} + +

+ 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. +

+ + + + )} + {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. +

+ + +

+ {sneak?.blocked ?? + `Up to ${maximum} forces are available for this entry.`} +

+ + + + )} + {(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.'} +

+ + + + + )} +
+
+ ); +} 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} +

+ )} + +
+
+ ); +} + +/** 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}

+ + + +
+ ))} +
+
+ ); +} 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.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} +

+ )} + +
+ ); +} 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} +

+ )} + +
+
+ ); +} 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} +

+ )} + +
+
+ ); +} + +/** 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. +

+ )} + + + ) : ( +

+ 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 ( + + ); + })} +
+ )} + {g.players + .filter((p) => p.id !== me.id) + .map((p) => ( +
+ + {p.name} · {p.tanks} forces in the tanks{' '} + + +
+ ))} + {me.ally && ( + + )} + {Object.entries(g.revivalRequests).map(([id, request]) => ( +
+

+ {g.players.find((p) => p.id === id)?.name} requests{' '} + {name(request.leader)}. +

+ + + +

+ {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 && ( + + )} + + + ) : canRequest && (eligible.length > 0 || me.kwisatz?.dead) ? ( + <> + + + + ) : ( +

+ 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. +

+ )} + + +

Destination: {target.name}. Select another territory on the map.

+ + + + +

+ {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}
  • + ))} +
+ )} + +
+
+ ); +} + +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 && ( + + )} + + +
+ ); +} 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. +

+ + +
+ ); + 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.

+ )} + + +
+ ); + } + 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 ? ( + <> + + + {selected && ( + <> + + + + )} + + + {chosenMethod === 'onceAround' && ( + <> + + + + )} + {blackMarket && ( + <> + + 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.'} +

+ + + ) : ( +

No card is available to offer.

+ )} + {blackMarket && ( + + )} +
+ ); +} + +/** 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 && ( + <> + + update({ amount: event.target.value })} + /> + {auction.allyAvailable > 0 && ( + <> + + 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.'} +

+ + + )} + + + ) : ( +

+ {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 && ( +
+ + +
+ )} + + )} + {reason && ( +

+ {reason} +

+ )} + + {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. +

+ + {!info.canReveal && ( +

+ {info.revealBlock ?? + 'Voluntary revelation is not available now.'} +

+ )} + + )} + {showShipment && ( + <> + + +

+ 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. +

+ + + + )} +
+
+ ); +} 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 && } + + )} + {purchase.cards.length > 0 && ( + <> + + + {acquired && ( +
+ + +
+ )} + + )} + {reason && ( +

+ {reason} +

+ )} + +
+
+ ); +} 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) => ( +
  1. {step}
  2. + ))} +
+ {topic.id === 'stronghold-cards' && ( + + )} + {topic.id === 'richese-cards' && ( +
+ {RICHESE_CARD_DEFINITIONS.map(({ card, printedType }) => ( +
+

+ {card.name} +

+

+ {printedType} · 1 card +

+ + +
+ ))} +
+ )} + {topic.checklist && ( +
+

Feature checklist

+
+ + + + + + + + + + + {topic.checklist.map((item) => ( + + + + + + ))} + +
+ {topic.title}: implementation, controls, AI, + documentation and verification +
+ Area + + Status + + Scope and evidence +
+ {item.area} + {item.status} +

{item.detail}

+ {item.evidence && ( +
    + {item.evidence.map((file) => ( +
  • + + {file} + +
  • + ))} +
+ )} +
+
+
+ )} + {topic.id === 'battle' && ( + + )} + {topic.example && ( + + )} + {topic.related && ( + + )} + 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 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. +

+
+ +
+ ); + + 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. +

+
+ + +

+ {BOT_DESCRIPTIONS[difficulty]} +

+
+ +
+
+ ); +} 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 ? ( + + ) : ( +
+ +