Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
+38
@@ -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/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"project_id": "appgprj_6a9b7996c1a4819182205bafb527a97f",
|
||||
"d1": "DB",
|
||||
"r2": null
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
authenticate,
|
||||
recoverSeat,
|
||||
SeatControlError,
|
||||
setRecoveryKey,
|
||||
} from '@/db/rooms';
|
||||
import { RuleError } from '@/game/engine';
|
||||
const noStore = { 'Cache-Control': 'no-store' };
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
if (req.headers.get('origin') && req.headers.get('origin') !== url.origin)
|
||||
throw new SeatControlError(
|
||||
'Invalid request origin.',
|
||||
'INVALID_ORIGIN',
|
||||
403,
|
||||
);
|
||||
const code = url.pathname.split('/').at(-2)!.toUpperCase();
|
||||
if (!/^[A-Z2-9]{8}$/.test(code))
|
||||
throw new SeatControlError(
|
||||
'Enter the eight-character room code.',
|
||||
'INVALID_CONTROL_REQUEST',
|
||||
400,
|
||||
);
|
||||
const raw = await req.text();
|
||||
if (raw.length > 4000)
|
||||
throw new SeatControlError(
|
||||
'Request too large.',
|
||||
'REQUEST_TOO_LARGE',
|
||||
413,
|
||||
);
|
||||
const body: unknown = JSON.parse(raw);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new SeatControlError(
|
||||
'Choose a recovery operation.',
|
||||
'INVALID_CONTROL_REQUEST',
|
||||
400,
|
||||
);
|
||||
const input = body as Record<string, unknown>;
|
||||
if (input.type === 'setRecoveryKey') {
|
||||
const token =
|
||||
req.headers
|
||||
.get('cookie')
|
||||
?.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`dune_${code}=`))
|
||||
?.split('=')[1] ?? '';
|
||||
const auth = await authenticate(code, token);
|
||||
return Response.json(
|
||||
await setRecoveryKey(code, auth, input.version, input.recoverySecret),
|
||||
{ headers: noStore },
|
||||
);
|
||||
}
|
||||
if (input.type !== 'recoverSeat')
|
||||
throw new SeatControlError(
|
||||
'Choose a recovery operation.',
|
||||
'INVALID_CONTROL_REQUEST',
|
||||
400,
|
||||
);
|
||||
const result = await recoverSeat(code, {
|
||||
playerId: input.playerId,
|
||||
recoverySecret: input.recoverySecret,
|
||||
operationId: input.operationId,
|
||||
newSessionToken: input.newSessionToken,
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
view: result.view,
|
||||
recovered: result.recovered,
|
||||
replayed: result.replayed,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
...noStore,
|
||||
'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${url.protocol === 'https:' ? '; Secure' : ''}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
error instanceof RuleError
|
||||
? error.message
|
||||
: error instanceof SyntaxError
|
||||
? 'Invalid JSON.'
|
||||
: 'The server could not complete seat recovery.',
|
||||
code:
|
||||
error instanceof SeatControlError
|
||||
? error.code
|
||||
: error instanceof RuleError
|
||||
? 'INVALID_SESSION'
|
||||
: error instanceof SyntaxError
|
||||
? 'INVALID_CONTROL_REQUEST'
|
||||
: 'SERVER_ERROR',
|
||||
},
|
||||
{
|
||||
status:
|
||||
error instanceof SeatControlError
|
||||
? error.status
|
||||
: error instanceof RuleError
|
||||
? 409
|
||||
: error instanceof SyntaxError
|
||||
? 400
|
||||
: 500,
|
||||
headers: noStore,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
readSeatView,
|
||||
authenticate,
|
||||
joinRoom,
|
||||
act,
|
||||
RoomEntryError,
|
||||
continueRoomBots,
|
||||
continueRoomAutomatic,
|
||||
needsAutomaticRoomRecovery,
|
||||
type SeatAuth,
|
||||
} from '@/db/rooms';
|
||||
import { RuleError } from '@/game/engine';
|
||||
import type { FactionId } from '@/game/catalog';
|
||||
import { waitUntil } from 'cloudflare:workers';
|
||||
// Coalesce overlapping polls inside this isolate. Database CAS still fences
|
||||
// other isolates, restarts, seat recovery, and human actions.
|
||||
const continuations = new Map<string, Promise<void>>();
|
||||
function resumeRoom(code: string): Promise<void> {
|
||||
const existing = continuations.get(code);
|
||||
if (existing) return existing;
|
||||
const pending = (async () => {
|
||||
await continueRoomAutomatic(code);
|
||||
await continueRoomBots(code);
|
||||
})().finally(() => {
|
||||
if (continuations.get(code) === pending) continuations.delete(code);
|
||||
});
|
||||
continuations.set(code, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
const codeOf = (req: Request) => {
|
||||
const code = new URL(req.url).pathname.split('/').pop()!.toUpperCase();
|
||||
if (!/^[A-Z2-9]{8}$/.test(code))
|
||||
throw new RuleError('Enter the eight-character room code.');
|
||||
return code;
|
||||
};
|
||||
const tokenOf = (req: Request, code: string) =>
|
||||
req.headers
|
||||
.get('cookie')
|
||||
?.split(';')
|
||||
.map((x) => x.trim())
|
||||
.find((x) => x.startsWith(`dune_${code}=`))
|
||||
?.split('=')[1] ?? '';
|
||||
const failure = (e: unknown) =>
|
||||
Response.json(
|
||||
{
|
||||
...(e instanceof RoomEntryError ? { code: e.code } : {}),
|
||||
error:
|
||||
e instanceof RuleError
|
||||
? e.message
|
||||
: e instanceof SyntaxError
|
||||
? 'Invalid JSON.'
|
||||
: 'The game server could not complete this request.',
|
||||
},
|
||||
{
|
||||
status:
|
||||
e instanceof RoomEntryError
|
||||
? e.status
|
||||
: e instanceof RuleError
|
||||
? 409
|
||||
: e instanceof SyntaxError
|
||||
? 400
|
||||
: 500,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
},
|
||||
);
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const code = codeOf(req),
|
||||
id = await authenticate(code, tokenOf(req, code));
|
||||
const view = await readSeatView(code, id);
|
||||
// Reconnect resumes persisted automatic effects/acknowledgements and AI work
|
||||
// after an interruption. Actual human choices remain engine-owned decisions.
|
||||
if (view.botsPending || needsAutomaticRoomRecovery(view))
|
||||
waitUntil(resumeRoom(code));
|
||||
return Response.json(view, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (e) {
|
||||
return failure(e);
|
||||
}
|
||||
}
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
if (
|
||||
req.headers.get('origin') &&
|
||||
req.headers.get('origin') !== new URL(req.url).origin
|
||||
)
|
||||
return Response.json(
|
||||
{ error: 'Invalid request origin.' },
|
||||
{ status: 403 },
|
||||
);
|
||||
const code = codeOf(req);
|
||||
const raw = await req.text();
|
||||
if (raw.length > 8000)
|
||||
return Response.json({ error: 'Request too large.' }, { status: 413 });
|
||||
const body = JSON.parse(raw);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new RuleError('The request is invalid.');
|
||||
if (body.type === 'join') {
|
||||
const existing = tokenOf(req, code);
|
||||
let auth: SeatAuth | null = null;
|
||||
if (existing) {
|
||||
try {
|
||||
auth = await authenticate(code, existing);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
}
|
||||
}
|
||||
if (body.entry === undefined && auth) {
|
||||
return Response.json(await readSeatView(code, auth), {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
if (typeof body.name !== 'string' || typeof body.faction !== 'string')
|
||||
throw new RuleError('Enter your name and faction.');
|
||||
const r = await joinRoom(
|
||||
code,
|
||||
body.name,
|
||||
body.faction as FactionId,
|
||||
body.entry,
|
||||
auth,
|
||||
);
|
||||
return Response.json(
|
||||
{
|
||||
...r.view,
|
||||
...(r.entryReceipt ? { entryReceipt: r.entryReceipt } : {}),
|
||||
...(r.alreadySeated ? { alreadySeated: true } : {}),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
...(r.token
|
||||
? {
|
||||
'Set-Cookie': `dune_${code}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`,
|
||||
}
|
||||
: {}),
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const id = await authenticate(code, tokenOf(req, code));
|
||||
if (
|
||||
!Number.isSafeInteger(body.version) ||
|
||||
!body.action ||
|
||||
typeof body.action !== 'object'
|
||||
)
|
||||
throw new RuleError('The action is invalid.');
|
||||
const view = await act(code, id, body.version, body.action);
|
||||
if (view.botsPending || needsAutomaticRoomRecovery(view))
|
||||
waitUntil(resumeRoom(code));
|
||||
return Response.json(view, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (e) {
|
||||
return failure(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createRoom, RoomEntryError } from '@/db/rooms';
|
||||
import { RuleError } from '@/game/engine';
|
||||
import type { FactionId } from '@/game/catalog';
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
if (
|
||||
req.headers.get('origin') &&
|
||||
req.headers.get('origin') !== new URL(req.url).origin
|
||||
)
|
||||
return Response.json(
|
||||
{ error: 'Invalid request origin.' },
|
||||
{ status: 403 },
|
||||
);
|
||||
const raw = await req.text();
|
||||
if (raw.length > 4000)
|
||||
return Response.json({ error: 'Request too large.' }, { status: 413 });
|
||||
const body = JSON.parse(raw);
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== 'object' ||
|
||||
Array.isArray(body) ||
|
||||
typeof body.name !== 'string' ||
|
||||
typeof body.faction !== 'string' ||
|
||||
!Array.isArray(body.expansions) ||
|
||||
typeof body.advanced !== 'boolean'
|
||||
)
|
||||
throw new RuleError('Enter a name, faction and rules selection.');
|
||||
const result = await createRoom(
|
||||
body.name,
|
||||
body.faction as FactionId,
|
||||
body.advanced,
|
||||
body.expansions,
|
||||
body.entry,
|
||||
);
|
||||
return Response.json(
|
||||
{
|
||||
...result.view,
|
||||
...(result.entryReceipt ? { entryReceipt: result.entryReceipt } : {}),
|
||||
},
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
'Set-Cookie': `dune_${result.view.code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${result.view.code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.json(
|
||||
{
|
||||
...(e instanceof RoomEntryError ? { code: e.code } : {}),
|
||||
error:
|
||||
e instanceof RuleError
|
||||
? e.message
|
||||
: e instanceof SyntaxError
|
||||
? 'Invalid JSON.'
|
||||
: 'Unable to create the room. Please retry.',
|
||||
},
|
||||
{
|
||||
status:
|
||||
e instanceof RoomEntryError
|
||||
? e.status
|
||||
: e instanceof RuleError || e instanceof SyntaxError
|
||||
? 400
|
||||
: 500,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+1901
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Dune — Arrakis Table',
|
||||
description:
|
||||
'A multiplayer table for classic Dune. Gather your rivals and battle for Arrakis.',
|
||||
};
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<TooltipProvider delay={200}>{children}</TooltipProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+1049
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,122 @@
|
||||
import Link from 'next/link';
|
||||
export default function Rules() {
|
||||
return (
|
||||
<main className="rule-page">
|
||||
<Link href="/">← Return to the table</Link>
|
||||
<h1>Rules coverage ledger</h1>
|
||||
<p>
|
||||
This project targets classic Gale Force Nine Dune, the advanced game,
|
||||
and all three faction expansions. The current build is in development
|
||||
and does not yet provide full rules compliance. Playable tables use
|
||||
Basic rules with the six base factions; optional tech tokens require at
|
||||
least three players. Advanced and expansion table starts remain disabled
|
||||
while their remaining rules and complete games are verified.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Area</th>
|
||||
<th>Current implementation</th>
|
||||
<th>Verification remaining</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
[
|
||||
'Multiplayer',
|
||||
'Persistent rooms, private seats, version checks, reconnects',
|
||||
'Hosted deployment and broader game scenarios',
|
||||
],
|
||||
[
|
||||
'Setup',
|
||||
'Staged base setup: secret Bene Gesserit prediction before traitor dealing, all traitor choices before force placement, and starting treachery after placement. Advanced Fremen and advisor setup verified offline; existing saved setups keep their dealt cards',
|
||||
'Complete Advanced rules and browser acceptance, expansion setup and optional-module setup; sampled complete games alone do not enable Advanced play',
|
||||
],
|
||||
[
|
||||
'Storm & spice',
|
||||
'Printed player circles and first-storm ordering audited; secret dials, double spice blow, Fremen forecasts, storm casualty choices, worms, Nexus and rides implemented',
|
||||
'Full Advanced-game integration and remaining expansion storm/spice interactions',
|
||||
],
|
||||
[
|
||||
'Economy',
|
||||
'Charity, auctions, revival, Fremen ally revival, ally funding, Guild and Emperor income, and Advanced stronghold income',
|
||||
'Auction clock and remaining power/payment timing windows',
|
||||
],
|
||||
[
|
||||
'Movement',
|
||||
'Territories, sector groups, routes, ornithopters, per-move Fremen Karama cancellation, Guild transport with private price/funding previews and contributor-based income, Advanced turn timing, BG free shipments, advisor stances and intrusion choices',
|
||||
'Temporary allied entry with enforced departure, printed-board graph audit, remaining advisor timing and Guild cancellation cases, and combined expansion entry effects',
|
||||
],
|
||||
[
|
||||
'Battle',
|
||||
'Sealed plans, weapons, defenses, ties, traitors, Harkonnen ally support, Voice, element-only prescience, lasgun/shield, optional winner discards, spice combat, elite casualties, Kwisatz Haderach, captured leaders and advisor exclusions',
|
||||
'Remaining advisor, special-power and timing interactions; complete Advanced battles within full games',
|
||||
],
|
||||
[
|
||||
'Victory',
|
||||
'Basic stronghold targets, alliances, Fremen/Guild special wins, prediction',
|
||||
'Rulebook scenarios and contested stronghold checks',
|
||||
],
|
||||
[
|
||||
'Treachery cards',
|
||||
'Base inventory; Hajr, Ghola, Weather Control, Family Atomics, Harvester, Karama auctions, shipment benefit, faction response windows and Truthtrance card, traitor and current-spice facts, battle commitments with Ghola preparation and freeform questions',
|
||||
'Remaining Karama powers, Truthtrance arbitrary commitments, other future card/power sequences and timing windows',
|
||||
],
|
||||
[
|
||||
'Special Karama',
|
||||
'Atreides plan inspection, Emperor revival, Fremen worm, Harkonnen hand exchange, Guild shipment stop, Tleilaxu revival prevention, Ixian stronghold relocation, CHOAM cash-in and Richese acquisition',
|
||||
'Expansion powers; pre-reveal timing, worm/card sequencing, auction recovery and canceled-shipment settlement clarification',
|
||||
],
|
||||
[
|
||||
'Ixians & Tleilaxu',
|
||||
'Full fourteen-card inventory, tech tokens, special battle cards, Sandtrout, Thumper, Cheap Hero traitor, Amal, Tleilaxu Face Dancers, Zoal, revival commerce, foreign gholas and Ixian combat/substitution, movement, revival pricing, mobile stronghold and setup/bidding technology',
|
||||
'Special-Karama timing/exception audit, mobile-stronghold collection/entry audits, complete-game setup, multi-Harvester/card-face audits and other auction modes, two-player tokens and expansion interactions',
|
||||
],
|
||||
[
|
||||
'CHOAM & Richese',
|
||||
'Partial mechanics: CHOAM charity, revival, Inflation, sales, allied exchanges, battle funding, five Worthless powers and Auditor inspection; Richese auctions, Black Market, No-Fields, allied shipments, card gifts and seven card effects; all six Stronghold Card effects, custody, inspectors and mobile choices',
|
||||
'Kull Wahad, Auditor repeat-revival cycles, Semuta Drug, Mirror Weapon, Juice of Sapho, leader skills, complete Stronghold module combinations; remaining timing, payment and full-game checks',
|
||||
],
|
||||
[
|
||||
'Ecaz & Moritani',
|
||||
'Partial mechanics: Ambassador placement and five entry effects, Moritani Terror placement and supported reactions, Extortion, alliance powers, advanced leader assassination, and Moritani acquisition and battle use of Duke Vidal',
|
||||
'Remaining Ambassador effects, Atomics/Aftermath, Ecaz setup and Vidal revival/control, competing entry effects, homeworlds, Nexus cards, discoveries and complete games',
|
||||
],
|
||||
[
|
||||
'Presentation',
|
||||
'Original desert artwork, interactive map, private hand, phase animations, portraits for all 60 ordinary leaders and a separate Duke Vidal portrait',
|
||||
'Other special components, reveal animations, and broader responsive and accessibility review',
|
||||
],
|
||||
].map(([a, b, c]) => (
|
||||
<tr key={a}>
|
||||
<td>{a}</td>
|
||||
<td>{b}</td>
|
||||
<td>{c}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Verification checkpoint: 1,631 rules, client and component tests and 152
|
||||
persistence/API tests pass. Advanced setup has 456 roster, difficulty
|
||||
and seating-order cases. A separate study completed 456 Advanced base
|
||||
games across two through six players. Another 40 sampled base-faction
|
||||
games with Stronghold Cards completed across every count and AI level,
|
||||
with no rejected actions or stalls. Remaining Advanced rules and browser
|
||||
acceptance, expansion games and optional modules still need
|
||||
verification; these checks do not establish full rules compliance.
|
||||
</p>
|
||||
<h2>About this adaptation</h2>
|
||||
<p>
|
||||
Rules are being checked against the GF9 base rulebook, November 2020
|
||||
FAQ, and the Ixians & Tleilaxu, CHOAM & Richese, and Ecaz &
|
||||
Moritani expansion rulebooks.
|
||||
</p>
|
||||
<p>
|
||||
Board geometry adapted from the MIT-licensed Truthsayer project. Faction
|
||||
and game names identify the board game being implemented. This is an
|
||||
unofficial fan project.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { RulesReference } from '@/components/rules-reference';
|
||||
export default function Rules() {
|
||||
return <RulesReference />;
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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<Record<string, number>>({});
|
||||
const [elites, setElites] = useState<Record<string, number>>({});
|
||||
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 (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
No legal relocation is currently available.
|
||||
</p>
|
||||
);
|
||||
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 (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-4"
|
||||
aria-label="Ambassador force relocation"
|
||||
>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Relocate a group from one territory. This does not spend your ordinary
|
||||
shipment or movement. The Ambassador has already been triggered.
|
||||
</p>
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-source`}>
|
||||
Source territory
|
||||
<select
|
||||
id={`${id}-source`}
|
||||
className="min-h-11"
|
||||
disabled={busy}
|
||||
value={source.territory}
|
||||
onChange={(event) => {
|
||||
setSourceId(event.target.value);
|
||||
setDestinationKey('');
|
||||
setForces({});
|
||||
setElites({});
|
||||
setTakeMarker(false);
|
||||
setFighters(false);
|
||||
}}
|
||||
>
|
||||
{sources.map((s) => (
|
||||
<option key={s.territory} value={s.territory}>
|
||||
{territory(s.territory).name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{rows.map((s) => (
|
||||
<fieldset
|
||||
className="flex min-w-0 flex-col gap-3 rounded-lg border p-3"
|
||||
key={s.key}
|
||||
disabled={busy}
|
||||
>
|
||||
<legend className="px-1 text-sm">
|
||||
Sector {splitLocation(s.key).sector} · {s.forces} physical forces
|
||||
available
|
||||
</legend>
|
||||
<label
|
||||
className="flex flex-col gap-2"
|
||||
htmlFor={`${id}-forces-${s.key}`}
|
||||
>
|
||||
Physical forces to relocate
|
||||
<Input
|
||||
id={`${id}-forces-${s.key}`}
|
||||
className="min-h-11"
|
||||
type="number"
|
||||
min={0}
|
||||
max={s.forces}
|
||||
step={1}
|
||||
value={s.count}
|
||||
disabled={s.key === target}
|
||||
onChange={(e) =>
|
||||
setForces({
|
||||
...forces,
|
||||
[s.key]: bounded(Number(e.target.value), 0, s.forces),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{s.elites > 0 && (
|
||||
<label
|
||||
className="flex flex-col gap-2"
|
||||
htmlFor={`${id}-elites-${s.key}`}
|
||||
>
|
||||
Elite forces included · {s.minimum}–{s.maximum}
|
||||
<Input
|
||||
id={`${id}-elites-${s.key}`}
|
||||
className="min-h-11"
|
||||
type="number"
|
||||
min={s.minimum}
|
||||
max={s.maximum}
|
||||
step={1}
|
||||
value={s.elite}
|
||||
disabled={s.key === target || !s.count}
|
||||
onChange={(e) =>
|
||||
setElites({
|
||||
...elites,
|
||||
[s.key]: bounded(
|
||||
Number(e.target.value),
|
||||
s.minimum,
|
||||
s.maximum,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{s.key === target && (
|
||||
<p className="m-0 text-sm">
|
||||
Forces already in the destination sector stay in place.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
))}
|
||||
{source.marker && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex min-h-11 items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={busy || markerAtTarget}
|
||||
checked={includesMarker}
|
||||
onChange={(e) => setTakeMarker(e.target.checked)}
|
||||
/>
|
||||
Include concealed No-Field from sector {source.marker.sector}
|
||||
</label>
|
||||
<p className="m-0 text-sm">
|
||||
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.'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-destination`}>
|
||||
Destination territory
|
||||
<select
|
||||
id={`${id}-destination`}
|
||||
className="min-h-11"
|
||||
disabled={busy}
|
||||
value={destination?.territory ?? ''}
|
||||
onChange={(e) => {
|
||||
const options = destinations.filter(
|
||||
(d) => d.territory === e.target.value,
|
||||
);
|
||||
const chosen = options.find((d) => !d.blocked) ?? options[0];
|
||||
if (chosen)
|
||||
chooseDestination(location(chosen.territory, chosen.sector));
|
||||
}}
|
||||
>
|
||||
{targetTerritories.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{territory(t).name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-sector`}>
|
||||
Destination sector
|
||||
<select
|
||||
id={`${id}-sector`}
|
||||
className="min-h-11"
|
||||
disabled={busy}
|
||||
value={target}
|
||||
onChange={(e) => chooseDestination(e.target.value)}
|
||||
>
|
||||
{destinations
|
||||
.filter((d) => d.territory === destination?.territory)
|
||||
.map((d) => (
|
||||
<option key={d.sector} value={location(d.territory, d.sector)}>
|
||||
{d.sector === 0 ? 'Sheltered interior' : d.sector}
|
||||
{d.blocked ? ' · Unavailable' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{destination?.advisors && (
|
||||
<p className="m-0 text-sm">This group arrives as advisors.</p>
|
||||
)}
|
||||
{destination?.canFight && (
|
||||
<label className="flex min-h-11 items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fighters}
|
||||
disabled={busy}
|
||||
onChange={(e) => setFighters(e.target.checked)}
|
||||
/>
|
||||
Request a flip to fighters on arrival
|
||||
</label>
|
||||
)}
|
||||
<p className="m-0 text-sm" aria-live="polite">
|
||||
Selected: {total} physical forces
|
||||
{includesMarker ? ' and one concealed No-Field' : ''}.
|
||||
</p>
|
||||
{reason && (
|
||||
<p className="m-0 text-sm leading-6" id={`${id}-reason`}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (busy || reason || !destination) return;
|
||||
act({
|
||||
type: 'decision',
|
||||
event: entry.event,
|
||||
forces: selectedForces,
|
||||
eliteForces: selectedElites,
|
||||
territory: destination.territory,
|
||||
sector: destination.sector,
|
||||
...(includesMarker && source.marker
|
||||
? {
|
||||
noField: {
|
||||
tokenId: source.marker.tokenId,
|
||||
event: source.marker.event,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(fighters && destination.canFight ? { fighters: true } : {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Relocate selected forces
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(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 (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-4"
|
||||
aria-label="Ambassador reserve shipment"
|
||||
>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
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.
|
||||
</p>
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-amount`}>
|
||||
Physical forces to ship · up to {maximum}
|
||||
<Input
|
||||
id={`${id}-amount`}
|
||||
className="min-h-11"
|
||||
type="number"
|
||||
min={maximum ? 1 : 0}
|
||||
max={maximum}
|
||||
step={1}
|
||||
value={amount}
|
||||
disabled={busy || !maximum}
|
||||
onChange={(e) =>
|
||||
setCount(bounded(Number(e.target.value), 1, maximum))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{shipment.eliteReserves > 0 && (
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-elites`}>
|
||||
Elite forces included · {minimumElite}–{maximumElite}
|
||||
<Input
|
||||
id={`${id}-elites`}
|
||||
className="min-h-11"
|
||||
type="number"
|
||||
min={minimumElite}
|
||||
max={maximumElite}
|
||||
step={1}
|
||||
value={elite}
|
||||
disabled={busy || !amount}
|
||||
onChange={(e) =>
|
||||
setEliteCount(
|
||||
bounded(Number(e.target.value), minimumElite, maximumElite),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-territory`}>
|
||||
Destination territory
|
||||
<select
|
||||
id={`${id}-territory`}
|
||||
className="min-h-11"
|
||||
value={destination?.territory ?? ''}
|
||||
disabled={busy || !shipment.destinations.length}
|
||||
onChange={(e) => {
|
||||
const options = shipment.destinations.filter(
|
||||
(d) => d.territory === e.target.value,
|
||||
);
|
||||
const chosen = options.find((d) => !d.blocked) ?? options[0];
|
||||
if (chosen)
|
||||
setDestinationKey(location(chosen.territory, chosen.sector));
|
||||
}}
|
||||
>
|
||||
{territories.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{territory(t).name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-sector`}>
|
||||
Destination sector
|
||||
<select
|
||||
id={`${id}-sector`}
|
||||
className="min-h-11"
|
||||
value={
|
||||
destination
|
||||
? location(destination.territory, destination.sector)
|
||||
: ''
|
||||
}
|
||||
disabled={busy || !destination}
|
||||
onChange={(e) => setDestinationKey(e.target.value)}
|
||||
>
|
||||
{shipment.destinations
|
||||
.filter((d) => d.territory === destination?.territory)
|
||||
.map((d) => (
|
||||
<option key={d.sector} value={location(d.territory, d.sector)}>
|
||||
{d.sector === 0 ? 'Sheltered interior' : d.sector}
|
||||
{d.blocked ? ' · Unavailable' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{destination?.advisors && (
|
||||
<p className="m-0 text-sm">These forces join your advisors.</p>
|
||||
)}
|
||||
<p className="m-0 text-sm" aria-live="polite">
|
||||
Selected: {amount} physical forces, including {elite} elites. Cost: 0
|
||||
spice.
|
||||
</p>
|
||||
<p className="m-0 text-sm">
|
||||
No-Field substitution for this effect remains unfinished.
|
||||
</p>
|
||||
{reason && (
|
||||
<p id={`${id}-reason`} className="m-0 text-sm leading-6">
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !reason && destination)
|
||||
act({
|
||||
type: 'decision',
|
||||
event: entry.event,
|
||||
amount,
|
||||
elite,
|
||||
territory: destination.territory,
|
||||
sector: destination.sector,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Ship selected forces for free
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!busy) act({ type: 'decision', event: entry.event, amount: 0 });
|
||||
}}
|
||||
>
|
||||
Send no forces
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AutomaticActionEvent[]>([]);
|
||||
const active = useRef<AutomaticActionEvent | null>(null);
|
||||
const [notice, setNotice] = useState<
|
||||
(AutomaticActionEvent & { fading: boolean }) | null
|
||||
>(null);
|
||||
const [completed, setCompleted] = useState(0);
|
||||
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 max-w-full whitespace-normal motion-reduce:transition-none"
|
||||
aria-label="Automatic action notices"
|
||||
aria-pressed={enabled}
|
||||
onClick={toggle}
|
||||
>
|
||||
{enabled ? <Bell aria-hidden="true" /> : <BellOff aria-hidden="true" />}
|
||||
Automatic action notices
|
||||
<span aria-hidden="true">{enabled ? 'On' : 'Off'}</span>
|
||||
</Button>
|
||||
<output
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="pointer-events-none fixed top-[max(1rem,env(safe-area-inset-top))] left-1/2 z-40 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2"
|
||||
>
|
||||
{enabled && notice && (
|
||||
<span
|
||||
className={`block rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl transition-opacity duration-250 motion-reduce:opacity-100 motion-reduce:transition-none ${notice.fading ? 'opacity-0' : 'opacity-100'}`}
|
||||
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
<span
|
||||
className="block text-xs font-semibold tracking-wider uppercase"
|
||||
style={{ color: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
{house?.name ?? 'Automatic action'}
|
||||
</span>
|
||||
<span className="mt-1 block break-words text-sm font-medium leading-5">
|
||||
{notice.name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<details className="battle-component-inspection">
|
||||
<summary>Inspect {playerName}’s plan components</summary>
|
||||
<div className="visible-component-list">
|
||||
{leader && (
|
||||
<div>
|
||||
<strong>{leader.name}</strong>
|
||||
<LeaderInspector identity={leader} />
|
||||
</div>
|
||||
)}
|
||||
{cards.map((card) => (
|
||||
<div key={card.id}>
|
||||
<strong>{card.name}</strong>
|
||||
<CardInspector card={card} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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 (
|
||||
<p className="notice">
|
||||
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.'}
|
||||
</p>
|
||||
);
|
||||
if (preparation.kind === 'voice')
|
||||
return (
|
||||
<>
|
||||
<h3>The Voice</h3>
|
||||
<p className="muted">
|
||||
{beneficiary.id === me.id
|
||||
? 'Command your opponent'
|
||||
: `Assist ${beneficiary.name}`}{' '}
|
||||
before Atreides chooses prescience and the combatants seal their
|
||||
plans.
|
||||
</p>
|
||||
<label htmlFor="voice-kind">
|
||||
Card type
|
||||
<select
|
||||
id="voice-kind"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
{VOICE_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{battleCardLabel(k)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="decision-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={must}
|
||||
onChange={(e) => setMust(e.target.checked)}
|
||||
/>
|
||||
Require this type (unchecked: forbid)
|
||||
</label>
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'voice', kind, must })}
|
||||
>
|
||||
Use the Voice
|
||||
</Button>
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'declineBattlePower' })}
|
||||
>
|
||||
Decline the Voice
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
const prescienceTarget =
|
||||
beneficiary.id === b.attacker ? b.defender : b.attacker;
|
||||
const noFieldTarget = b.noFieldPlayers.includes(prescienceTarget);
|
||||
if (preparation.kind === 'prescience')
|
||||
return (
|
||||
<>
|
||||
<h3>Battle prescience</h3>
|
||||
<p className="muted">
|
||||
Choose one element of{' '}
|
||||
{beneficiary.id === me.id ? 'your' : `${beneficiary.name}’s`}{' '}
|
||||
opponent’s plan. Only that element becomes fixed.
|
||||
</p>
|
||||
{noFieldTarget && (
|
||||
<p className="fine">
|
||||
A No-Field battle keeps the number dialed hidden from Atreides. You
|
||||
can inspect the leader, weapon, or defense.
|
||||
</p>
|
||||
)}
|
||||
{[
|
||||
'leader',
|
||||
'weapon',
|
||||
'defense',
|
||||
...(noFieldTarget ? [] : ['dial']),
|
||||
].map((field) => (
|
||||
<Button
|
||||
className="game-action"
|
||||
key={field}
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'prescience', field })}
|
||||
>
|
||||
Reveal {field}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'declineBattlePower' })}
|
||||
>
|
||||
Decline prescience
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
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 (
|
||||
<>
|
||||
<h3>Reveal your {field}</h3>
|
||||
<p className="muted">
|
||||
{beneficiary.name} will see this element. You may still change the rest
|
||||
of your plan before sealing it.
|
||||
</p>
|
||||
{b.voice && (
|
||||
<p className="notice">
|
||||
Voice: {b.voice.must ? 'must play' : 'cannot play'}{' '}
|
||||
{battleCardLabel(b.voice.kind)}.
|
||||
</p>
|
||||
)}
|
||||
<label htmlFor="prescience-answer">
|
||||
{field === 'dial' ? 'Forces dialed' : field}
|
||||
{field === 'dial' ? (
|
||||
<Input
|
||||
id="prescience-answer"
|
||||
type="number"
|
||||
min={0}
|
||||
max={game.advanced || me.faction === 'ixians' ? 40 : 20}
|
||||
step={game.advanced || me.faction === 'ixians' ? 0.5 : 1}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
id="prescience-answer"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{choices.map((choice) => (
|
||||
<option key={choice.id} value={choice.id}>
|
||||
{choice.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
{stoneReason && <p className="notice">{stoneReason}</p>}
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !!stoneReason || (field === 'dial' && value === '')}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'prescienceAnswer',
|
||||
value: field === 'dial' ? Number(value) : value || null,
|
||||
})
|
||||
}
|
||||
>
|
||||
Commit this element
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<label>
|
||||
Plan element
|
||||
<select
|
||||
value={c.kind}
|
||||
onChange={(e) => onChange(initial(e.target.value))}
|
||||
>
|
||||
<option value="dial">Dial</option>
|
||||
<option value="leader">Leader or hero</option>
|
||||
<option value="weapon">Weapon slot</option>
|
||||
<option value="defense">Defense slot</option>
|
||||
<option value="support">Spice support</option>
|
||||
<option value="kwisatz">Kwisatz Haderach</option>
|
||||
</select>
|
||||
</label>
|
||||
{(c.kind === 'dial' || c.kind === 'support') && (
|
||||
<>
|
||||
<label>
|
||||
Comparison
|
||||
<select
|
||||
value={c.compare}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...c,
|
||||
compare: e.target.value as 'eq' | 'gte' | 'lte',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="eq">Exactly</option>
|
||||
<option value="gte">At least</option>
|
||||
<option value="lte">At most</option>
|
||||
</select>
|
||||
</label>
|
||||
<label htmlFor={amountId}>
|
||||
Amount
|
||||
<Input
|
||||
id={amountId}
|
||||
type="number"
|
||||
min={0}
|
||||
max={40}
|
||||
step={c.kind === 'dial' ? 0.5 : 1}
|
||||
value={c.value}
|
||||
onChange={(e) =>
|
||||
onChange({ ...c, value: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{(c.kind === 'weapon' || c.kind === 'defense') && (
|
||||
<label>
|
||||
Card name
|
||||
<select
|
||||
value={c.name ?? ''}
|
||||
onChange={(e) => onChange({ ...c, name: e.target.value || null })}
|
||||
>
|
||||
<option value="">Empty slot</option>
|
||||
{TRUTH_CARD_NAMES.map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{c.kind === 'leader' && (
|
||||
<label>
|
||||
Leader
|
||||
<select
|
||||
value={c.leader ?? ''}
|
||||
onChange={(e) => onChange({ ...c, leader: e.target.value || null })}
|
||||
>
|
||||
<option value="">No leader or hero</option>
|
||||
{g.allLeaders.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={CHEAP_HERO_TRAITOR}>Cheap Hero / Heroine</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{c.kind === 'kwisatz' && (
|
||||
<label>
|
||||
Kwisatz use
|
||||
<select
|
||||
value={c.use ? 'yes' : 'no'}
|
||||
onChange={(e) => onChange({ ...c, use: e.target.value === 'yes' })}
|
||||
>
|
||||
<option value="yes">Included</option>
|
||||
<option value="no">Not included</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<section
|
||||
className="notice truthtrance-history"
|
||||
aria-label={
|
||||
b.truthPromises.length
|
||||
? 'Battle Truthtrance commitments'
|
||||
: 'Private battle preparation'
|
||||
}
|
||||
>
|
||||
<h3>
|
||||
{b.truthPromises.length
|
||||
? 'Truthtrance commitments'
|
||||
: 'Preparing your committed plan'}{' '}
|
||||
<HelpTip topic="truthBattle" />
|
||||
</h3>
|
||||
<ul>
|
||||
{b.truthPromises.map((p, i) => (
|
||||
<li key={i}>
|
||||
<p>
|
||||
{g.players.find((other) => other.id === p.player)?.name}:{' '}
|
||||
<strong>{p.answer ? 'Yes' : 'No'}</strong> to “
|
||||
{planClaimText(p.claim, name)}”
|
||||
{p.released ? ' · No longer possible; released' : ''}.
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{b.compliantPreparation && (
|
||||
<div>
|
||||
<p className="eyebrow">Preparation · private</p>
|
||||
{b.compliantPreparation.waitingForIncome && (
|
||||
<p>Revival income must resolve before this plan can be funded.</p>
|
||||
)}
|
||||
{b.compliantPreparation.actions.map((action, index) => (
|
||||
<p key={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` : ''}`}
|
||||
.
|
||||
</p>
|
||||
))}
|
||||
<p className="fine">
|
||||
{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.
|
||||
</p>
|
||||
{b.compliantPreparation.actions[0] && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
busy ||
|
||||
!!g.truthtrance ||
|
||||
!!g.response ||
|
||||
!!g.decision ||
|
||||
!!g.phaseOpening
|
||||
}
|
||||
onClick={() => act(b.compliantPreparation!.actions[0])}
|
||||
>
|
||||
Complete next preparation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{plan && (
|
||||
<details>
|
||||
<summary>Review a compliant plan · private</summary>
|
||||
<p>
|
||||
Dial {plan.dial} · Support {plan.support}
|
||||
{!!plan.allyPayment && ` · CHOAM pays ${plan.allyPayment}`}
|
||||
<br />
|
||||
Leader: {name(plan.leader)}
|
||||
<br />
|
||||
Weapon: {name(plan.weapon)}
|
||||
<br />
|
||||
Defense: {name(plan.defense)}
|
||||
<br />
|
||||
Kwisatz: {plan.kwisatz ? 'Included' : 'Not included'}
|
||||
</p>
|
||||
<p className="fine">
|
||||
These choices satisfy your current commitments. Review and change
|
||||
the other elements before sealing your plan.
|
||||
</p>
|
||||
<Button variant="outline" disabled={busy} onClick={() => fill(plan)}>
|
||||
Fill these plan choices
|
||||
</Button>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={styles.root} data-disabled={disabled || undefined}>
|
||||
<svg
|
||||
className={styles.wheel}
|
||||
viewBox="0 0 200 200"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={rimId} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="#e3c68a" />
|
||||
<stop offset="0.35" stopColor="#756344" />
|
||||
<stop offset="0.65" stopColor="#302d24" />
|
||||
<stop offset="1" stopColor="#b29966" />
|
||||
</linearGradient>
|
||||
<radialGradient id={faceId} cx="40%" cy="28%" r="75%">
|
||||
<stop offset="0" stopColor="#30362b" />
|
||||
<stop offset="1" stopColor="#131810" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="100" cy="102" r="96" fill="#080b07" opacity="0.6" />
|
||||
<circle cx="100" cy="100" r="95" fill={`url(#${rimId})`} />
|
||||
<circle cx="100" cy="100" r="91" fill="#181d15" />
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="87"
|
||||
fill="none"
|
||||
stroke="#736344"
|
||||
strokeWidth="0.6"
|
||||
/>
|
||||
{Array.from({ length: divisions + 1 }, (_, index) => {
|
||||
const position = index / divisions;
|
||||
const tickAngle = -135 + position * 270;
|
||||
const major = index % Math.max(1, Math.round(divisions / 4)) === 0;
|
||||
const outer = point(tickAngle, 83);
|
||||
const inner = point(tickAngle, major ? 73 : 78);
|
||||
return (
|
||||
<line
|
||||
key={index}
|
||||
x1={inner.x}
|
||||
y1={inner.y}
|
||||
x2={outer.x}
|
||||
y2={outer.y}
|
||||
stroke={position <= fraction ? '#e3c68a' : '#777762'}
|
||||
strokeWidth={major ? 1.7 : 0.8}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{labels.map((mark) => {
|
||||
const label = point(-135 + (max > 0 ? mark / max : 0) * 270, 62);
|
||||
return (
|
||||
<text
|
||||
key={mark}
|
||||
x={label.x}
|
||||
y={label.y}
|
||||
fill="#d1c4a4"
|
||||
fontSize="12"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{Number(mark.toFixed(2))}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="44"
|
||||
fill={`url(#${faceId})`}
|
||||
stroke="#89764d"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="#c6ad76"
|
||||
strokeOpacity="0.15"
|
||||
/>
|
||||
<path
|
||||
d="M 96 29 L 100 18 L 104 29 Z"
|
||||
fill="#f2d79b"
|
||||
stroke="#171b12"
|
||||
strokeWidth="0.8"
|
||||
transform={`rotate(${angle} 100 100)`}
|
||||
/>
|
||||
<text
|
||||
x="100"
|
||||
y="101"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className={styles.chosen}
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
<text
|
||||
x="100"
|
||||
y="124"
|
||||
textAnchor="middle"
|
||||
fill="#c0b290"
|
||||
fontSize="9"
|
||||
letterSpacing="1.6"
|
||||
>
|
||||
DIALED
|
||||
</text>
|
||||
<path d="M 85 173 H 115" stroke="#a28b5e" strokeWidth="1" />
|
||||
<circle cx="100" cy="180" r="1.5" fill="#a28b5e" />
|
||||
</svg>
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.slider}>
|
||||
<input
|
||||
className={styles.range}
|
||||
type="range"
|
||||
min={0}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label="Forces dialed slider"
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
<div className={styles.bounds} aria-hidden="true">
|
||||
<span>0</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className={styles.number}
|
||||
id={id}
|
||||
type="number"
|
||||
min={0}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<p className="card-rules text-sm leading-relaxed text-[#e2d9c6]">
|
||||
{cardPresentation(card).guidance}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 w-full motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
aria-label={`Inspect card: ${card.name}`}
|
||||
>
|
||||
<Eye aria-hidden="true" />
|
||||
Inspect card
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
|
||||
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-reduce:animate-none motion-reduce:transition-none sm:max-h-[calc(100dvh-3rem)] sm:w-[calc(100%-3rem)]">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
|
||||
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
|
||||
Card inspection
|
||||
</span>
|
||||
<DialogClose
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
Close
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||||
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<article
|
||||
className={`min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br ${faceColors[presentation.role]} p-5 shadow-lg sm:p-6`}
|
||||
>
|
||||
<div className="mb-6 flex items-start justify-between gap-4 border-b border-[#b59a64]/40 pb-4">
|
||||
<span className="text-xs font-semibold tracking-[0.12em] text-[#f1d79f] uppercase">
|
||||
{presentation.category}
|
||||
</span>
|
||||
<Icon
|
||||
className="size-6 shrink-0 text-[#edce90]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-auto mb-6 flex size-24 rotate-45 items-center justify-center rounded-2xl border border-[#c5a86d]/40 bg-[#d7bd81]/5"
|
||||
>
|
||||
<Icon className="size-12 -rotate-45 text-[#ddbc77]" />
|
||||
</div>
|
||||
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
|
||||
{card.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-5 text-base leading-7 text-[#eee5d2]">
|
||||
{presentation.guidance}
|
||||
</DialogDescription>
|
||||
<div className="mt-6 border-t border-[#b59a64]/40 pt-3 text-xs tracking-wider text-[#cebea0] uppercase">
|
||||
Treachery · gameplay guide
|
||||
</div>
|
||||
</article>
|
||||
<section
|
||||
className="flex min-w-0 flex-col gap-5"
|
||||
aria-label={`How to use ${card.name}`}
|
||||
>
|
||||
<p className="m-0 text-sm leading-6 text-[#c1c5b8]">
|
||||
{presentation.availability ??
|
||||
'Inspecting a card does not play it. Close this view to use the table’s action controls.'}
|
||||
</p>
|
||||
{presentation.gameplay && (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4 text-base leading-7 text-[#e0e4d8]">
|
||||
<h3 className="m-0 text-base font-semibold text-[#efd9a8]">
|
||||
Playing {card.name}
|
||||
</h3>
|
||||
{presentation.gameplay.map((paragraph) => (
|
||||
<p className="m-0" key={paragraph}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{presentation.topics.map((topic, index) => (
|
||||
<details
|
||||
key={topic.id}
|
||||
open={index === 0}
|
||||
className="rounded-lg border border-[#454b3c] bg-[#1d221b]"
|
||||
>
|
||||
<summary className="cursor-pointer rounded-lg px-4 py-3 text-base font-semibold text-[#efd9a8] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ddbc77]">
|
||||
{topic.title}
|
||||
</summary>
|
||||
<div className="flex flex-col gap-3 border-t border-[#454b3c] p-4 text-base leading-7 text-[#e0e4d8]">
|
||||
{topic.steps.map((step) => (
|
||||
<p className="m-0" key={step}>
|
||||
{step}
|
||||
</p>
|
||||
))}
|
||||
{topic.example && (
|
||||
<p className="m-0 rounded border-l-2 border-[#ddbc77] bg-[#ddbc77]/5 p-3">
|
||||
<strong>Example: </strong>
|
||||
{topic.example}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="min-w-0 space-y-3" aria-label="Auditor decision">
|
||||
<h3 className="font-serif text-xl">
|
||||
{payment ? 'Keep your hand private' : 'Use the Auditor'}
|
||||
</h3>
|
||||
<p className="text-base leading-7">
|
||||
{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.`}
|
||||
</p>
|
||||
{!payment && (
|
||||
<p className="text-base leading-7">
|
||||
Karama can prevent the audit. Your opponent can also cancel by paying
|
||||
one spice per card you would inspect.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
act(
|
||||
payment
|
||||
? {
|
||||
type: 'decision',
|
||||
event: audit.event,
|
||||
pay: true,
|
||||
count: audit.count,
|
||||
}
|
||||
: { type: 'decision', event: audit.event, audit: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
{payment
|
||||
? `Pay ${audit.count} spice · cancel audit`
|
||||
: 'Audit opposing hand'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
act(
|
||||
payment
|
||||
? { type: 'decision', event: audit.event, pay: false }
|
||||
: { type: 'decision', event: audit.event, audit: false },
|
||||
)
|
||||
}
|
||||
>
|
||||
{payment ? 'Allow inspection' : 'Decline audit'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
className="notice min-w-0 space-y-3"
|
||||
aria-label="Your private Auditor inspection"
|
||||
>
|
||||
<h3 className="font-serif text-xl">
|
||||
Your Auditor inspection · {opponent?.name}
|
||||
</h3>
|
||||
<p className="text-base leading-7">
|
||||
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.
|
||||
</p>
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
{insight.cards.map((card) => (
|
||||
<article
|
||||
key={card.id}
|
||||
className="min-w-0 space-y-3 rounded-lg border border-[#485542] p-4"
|
||||
>
|
||||
<h4 className="font-serif text-xl">{card.name}</h4>
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="notice">
|
||||
<h3>
|
||||
Baliset <HelpTip topic="choamWorthless" />
|
||||
</h3>
|
||||
<p>
|
||||
Prevent a player from moving into one territory you occupy this phase.
|
||||
Shipment remains possible. Karama may cancel the effect.
|
||||
</p>
|
||||
{d ? (
|
||||
<p>
|
||||
{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.
|
||||
</p>
|
||||
) : (
|
||||
card && (
|
||||
<>
|
||||
<label>
|
||||
Player{' '}
|
||||
<select
|
||||
value={selectedPlayer ?? ''}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
>
|
||||
{players.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Territory{' '}
|
||||
<select
|
||||
value={selectedTerritory ?? ''}
|
||||
onChange={(e) => setTerritory(e.target.value)}
|
||||
>
|
||||
{territories.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{name(t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{card && (
|
||||
<Button
|
||||
disabled={busy || !selectedPlayer || !selectedTerritory}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: card.id,
|
||||
target: selectedPlayer,
|
||||
territory: selectedTerritory,
|
||||
})
|
||||
}
|
||||
>
|
||||
Prevent movement
|
||||
</Button>
|
||||
)}
|
||||
{!card && d && <p className="fine">No available Baliset play.</p>}
|
||||
{d && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Allow movement
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="notice">
|
||||
<h3>
|
||||
Fund your ally’s battle forces <HelpTip topic="choamCombat" />
|
||||
</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>Your current unspent pledge: {g.aid.pledged} spice.</p>
|
||||
{g.battle?.choamAidBlocked && (
|
||||
<p>
|
||||
Karama prevents using your funding in this battle. Your pledge remains
|
||||
available for later battles.
|
||||
</p>
|
||||
)}
|
||||
<label>
|
||||
New unspent pledge{' '}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={(me.spice ?? 0) + g.aid.pledged}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
act({ type: deciding ? 'decision' : 'pledgeAid', amount })
|
||||
}
|
||||
>
|
||||
{deciding ? 'Confirm battle funding' : 'Update battle funding'}
|
||||
</Button>
|
||||
{deciding && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', amount: g.aid.pledged })}
|
||||
>
|
||||
Keep current pledge
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>([]);
|
||||
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 (
|
||||
<section className="notice">
|
||||
<h3>CHOAM special Karama</h3>
|
||||
<p>
|
||||
Once per game, spend Karama and discard other cards for three spice
|
||||
each. Committed battle cards are excluded. The activating Karama earns
|
||||
no spice.
|
||||
</p>
|
||||
<label>
|
||||
Karama to spend{' '}
|
||||
<select value={card.id} onChange={(e) => setActivation(e.target.value)}>
|
||||
{cash.karamas.map((c, i) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
Karama {i + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{choices.map((c) => (
|
||||
<label key={c.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cards.includes(c.id)}
|
||||
onChange={(e) =>
|
||||
setSelected(
|
||||
e.target.checked
|
||||
? [...selected, c.id]
|
||||
: selected.filter((id) => id !== c.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{c.name} · 3 spice
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !!g.truthtrance || cards.length === 0}
|
||||
onClick={() =>
|
||||
act({ type: 'card', mode: 'special', card: card.id, cards })
|
||||
}
|
||||
>
|
||||
Spend Karama · gain {cards.length * 3} spice
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="notice">
|
||||
<h3>
|
||||
Trip to Gamont <HelpTip topic="choamWorthless" />
|
||||
</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{card && options.length > 0 && (
|
||||
<>
|
||||
<label>
|
||||
Force to return{' '}
|
||||
<select
|
||||
value={chosen ? key(chosen) : ''}
|
||||
onChange={(e) => setSelection(e.target.value)}
|
||||
>
|
||||
{options.map((o) => {
|
||||
const loc = splitLocation(o.key),
|
||||
owner = g.players.find((p) => p.id === o.target)!;
|
||||
const type = o.noField
|
||||
? 'No-Field — reveal, then return one if present'
|
||||
: o.elite
|
||||
? owner.faction === 'ixians'
|
||||
? 'cyborg'
|
||||
: 'elite'
|
||||
: owner.faction === 'ixians'
|
||||
? 'suboid'
|
||||
: 'ordinary';
|
||||
return (
|
||||
<option key={key(o)} value={key(o)}>
|
||||
{owner.name} ·{' '}
|
||||
{
|
||||
gameTerritories(g).find((t) => t.id === loc.territory)
|
||||
?.name
|
||||
}{' '}
|
||||
· sector {loc.sector} · {type}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
disabled={busy || !chosen}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: card.id,
|
||||
target: chosen.target,
|
||||
from: chosen.key,
|
||||
elite: chosen.elite,
|
||||
})
|
||||
}
|
||||
>
|
||||
Return this force
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(!card || !options.length) && (
|
||||
<p className="fine">No available Trip to Gamont play.</p>
|
||||
)}
|
||||
{g.decision?.kind === 'choamMentat' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', done: true })}
|
||||
>
|
||||
Finish and check victory
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<p>
|
||||
CHOAM offers{' '}
|
||||
<strong>
|
||||
{market.offered?.name ?? 'a card no longer available'}
|
||||
</strong>
|
||||
. Choose one card to return. CHOAM will confirm before either hand
|
||||
changes.
|
||||
</p>
|
||||
{me.hand?.map((c) => (
|
||||
<Button
|
||||
className="game-action"
|
||||
key={c.id}
|
||||
disabled={busy || !market.offered}
|
||||
onClick={() => act({ type: 'decision', card: c.id })}
|
||||
>
|
||||
Return {c.name}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Decline trade
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
if (g.decision?.kind === 'choamTradeConfirm')
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
Give <strong>{market.offered?.name ?? 'an unavailable card'}</strong>{' '}
|
||||
and receive{' '}
|
||||
<strong>{market.returned?.name ?? 'an unavailable card'}</strong>.
|
||||
Both cards move together. This uses your one trade for the turn.
|
||||
</p>
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !market.offered || !market.returned}
|
||||
onClick={() => act({ type: 'decision', accept: true })}
|
||||
>
|
||||
Confirm exchange
|
||||
</Button>
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Decline trade
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{market.sales?.map((sale) => (
|
||||
<Button
|
||||
className="game-action"
|
||||
key={`${sale.card}-${sale.price}`}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'decision',
|
||||
mode: 'sell',
|
||||
card: sale.card,
|
||||
witness: sale.witness,
|
||||
})
|
||||
}
|
||||
>
|
||||
Sell {sale.witness ? 'surplus ' : ''}
|
||||
{me.hand?.find((c) => c.id === sale.card)?.name} · {sale.price} spice
|
||||
</Button>
|
||||
))}
|
||||
{!market.sales?.length && (
|
||||
<p className="fine">No eligible sales remain this phase.</p>
|
||||
)}
|
||||
{market.canTrade && (
|
||||
<>
|
||||
<h3>Offer your ally a card</h3>
|
||||
<p className="fine">
|
||||
Your ally chooses a return card; you confirm the exchange. Offers
|
||||
stay private to the two of you.
|
||||
</p>
|
||||
{me.hand?.map((c) => (
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
key={c.id}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
act({ type: 'decision', mode: 'trade', card: c.id })
|
||||
}
|
||||
>
|
||||
Offer {c.name}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', done: true })}
|
||||
>
|
||||
Finish this phase
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="notice">
|
||||
<h3>
|
||||
Jubba Cloak <HelpTip topic="choamWorthless" />
|
||||
</h3>
|
||||
<p>
|
||||
Protect your own forces in one territory from this storm movement. Spice
|
||||
and other factions’ forces remain exposed. Karama may cancel this
|
||||
effect.
|
||||
</p>
|
||||
{decision.protected.length > 0 && (
|
||||
<p>
|
||||
Protected:{' '}
|
||||
{decision.protected.map((t) => territory(t).name).join(', ')}.
|
||||
</p>
|
||||
)}
|
||||
<label>
|
||||
Threatened territory{' '}
|
||||
<select
|
||||
value={choice?.territory ?? ''}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
>
|
||||
{decision.territories.map((t) => (
|
||||
<option key={t.territory} value={t.territory}>
|
||||
{territory(t.territory).name} · {t.amount} forces · sectors{' '}
|
||||
{t.sectors.join(', ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{card ? (
|
||||
<Button
|
||||
disabled={busy || !choice}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: card.id,
|
||||
territory: choice.territory,
|
||||
})
|
||||
}
|
||||
>
|
||||
Protect this territory
|
||||
</Button>
|
||||
) : (
|
||||
<p className="fine">
|
||||
No available Jubba Cloak play. Continue when ready to accept the
|
||||
remaining losses.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Continue storm
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="notice">
|
||||
<h3>
|
||||
CHOAM Worthless powers <HelpTip topic="choamWorthless" />
|
||||
</h3>
|
||||
<p>
|
||||
{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.
|
||||
</p>
|
||||
{g.phase === 4 && !reactive && (
|
||||
<label>
|
||||
Player to deny free revival{' '}
|
||||
<select
|
||||
value={recipient ?? ''}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
>
|
||||
{g.choamWorthless.targets.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{g.players.find((p) => p.id === id)?.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{reactive && (
|
||||
<p>
|
||||
{g.players.find((p) => p.id === recipient)?.name} is requesting free
|
||||
force revival.
|
||||
</p>
|
||||
)}
|
||||
{g.choamWorthless.cards
|
||||
.filter((c) =>
|
||||
g.phase === 4 ? c.name === 'La La La' : c.name === 'Kulon',
|
||||
)
|
||||
.map((c) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
disabled={
|
||||
busy ||
|
||||
(g.phase === 5 &&
|
||||
(g.active !== me.id ||
|
||||
(me.moved ?? 0) >= (me.movesAllowed ?? 1))) ||
|
||||
(g.phase === 4 && !recipient)
|
||||
}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: c.id,
|
||||
target: recipient,
|
||||
})
|
||||
}
|
||||
>
|
||||
Use {c.name}
|
||||
</Button>
|
||||
))}
|
||||
{reactive && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Allow this free revival
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<details className="my-4 min-w-0">
|
||||
<summary className="min-h-11 cursor-pointer py-3">
|
||||
Distrans: transfer a card
|
||||
</summary>
|
||||
<div className="flex min-w-0 flex-col gap-4 py-3">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<CardInspector card={distrans.card} />
|
||||
{choice && (
|
||||
<>
|
||||
<label htmlFor={`${id}-target`}>Recipient</label>
|
||||
<select
|
||||
id={`${id}-target`}
|
||||
className="min-h-11 w-full min-w-0"
|
||||
value={choice.recipient}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setTarget(event.target.value);
|
||||
setSelection('');
|
||||
}}
|
||||
>
|
||||
{distrans.choices.map((candidate) => (
|
||||
<option key={candidate.recipient} value={candidate.recipient}>
|
||||
{game.players.find(
|
||||
(player) => player.id === candidate.recipient,
|
||||
)?.name ?? 'Player'}
|
||||
{candidate.blocked ? ' — unavailable' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{choice.cards.length > 0 && (
|
||||
<>
|
||||
<label htmlFor={`${id}-given`}>Your card to transfer</label>
|
||||
<select
|
||||
id={`${id}-given`}
|
||||
className="min-h-11 w-full min-w-0"
|
||||
value={given?.id ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(event) => setSelection(event.target.value)}
|
||||
>
|
||||
{choice.cards.map((card) => (
|
||||
<option key={card.id} value={card.id}>
|
||||
{card.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{given && (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<CardRules card={given} />
|
||||
<CardInspector card={given} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{reason && (
|
||||
<p id={`${id}-reason`} className="notice">
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !reason && choice && given)
|
||||
act({
|
||||
type: 'card',
|
||||
card: distrans.card.id,
|
||||
target: choice.recipient,
|
||||
give: given.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Give {given?.name ?? 'a card'} to {recipient?.name ?? 'the recipient'}{' '}
|
||||
and discard Distrans
|
||||
</Button>
|
||||
{!!choice?.unavailable.length && (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-3"
|
||||
aria-labelledby={`${id}-unavailable`}
|
||||
>
|
||||
<h4 id={`${id}-unavailable`}>
|
||||
Your cards unavailable for this transfer
|
||||
</h4>
|
||||
<ul className="flex list-none flex-col gap-4 p-0">
|
||||
{choice.unavailable.map(({ card, reason: blocked }) => (
|
||||
<li key={card.id} className="flex min-w-0 flex-col gap-2">
|
||||
<strong>{card.name}</strong>
|
||||
<p>{blocked}</p>
|
||||
<CardInspector card={card} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg
|
||||
viewBox="0 0 160 160"
|
||||
className={large ? 'mx-auto my-6 size-48 max-w-full' : 'size-20 shrink-0'}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={gradient} cx="35%" cy="25%" r="80%">
|
||||
<stop offset="0" stopColor="#795577" />
|
||||
<stop offset="1" stopColor="#251b31" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="80" cy="83" r="74" fill="#100f15" />
|
||||
<circle
|
||||
cx="80"
|
||||
cy="79"
|
||||
r="73"
|
||||
fill={`url(#${gradient})`}
|
||||
stroke="#d6bc80"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle
|
||||
cx="80"
|
||||
cy="79"
|
||||
r="65"
|
||||
fill="none"
|
||||
stroke="#c9b385"
|
||||
strokeOpacity="0.65"
|
||||
/>
|
||||
<path
|
||||
d="M 80 18 L 86 27 L 80 36 L 74 27 Z M 32 79 Q 32 39 63 30 M 128 79 Q 128 39 97 30 M 32 91 Q 41 125 64 133 M 128 91 Q 119 125 96 133"
|
||||
fill="none"
|
||||
stroke="#ddc892"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M 55 48 L 67 59 L 80 43 L 93 59 L 105 48 L 98 69 H 62 Z"
|
||||
fill="none"
|
||||
stroke="#f0dca7"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x="80"
|
||||
y="121"
|
||||
textAnchor="middle"
|
||||
fill="#fff1c9"
|
||||
fontFamily="Georgia, serif"
|
||||
fontSize="56"
|
||||
>
|
||||
6
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<string | null>(null);
|
||||
const art = specialLeaderArt(id, name);
|
||||
if (!art || failedSource === art.src) return <VidalDisc large={large} />;
|
||||
return (
|
||||
<span
|
||||
className={`relative block shrink-0 ${large ? 'mx-auto my-6 size-48 max-w-full' : 'size-20'}`}
|
||||
>
|
||||
<span className="absolute inset-0 overflow-hidden rounded-full border-2 border-[#d6bc80] bg-[#251b31]">
|
||||
<Image
|
||||
src={art.src}
|
||||
alt=""
|
||||
width={1254}
|
||||
height={1254}
|
||||
sizes={large ? '192px' : '80px'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setFailedSource(art.src)}
|
||||
className="h-full w-full object-cover"
|
||||
style={{ objectPosition: art.objectPosition }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-1 rounded-full border border-[#e6ce99]/40"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<section
|
||||
aria-label="Duke Prad Vidal shared leader"
|
||||
className="min-w-0 rounded-xl border border-[#75617a] bg-[#241f29] p-4 text-[#eee7de]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<VidalPortrait id={duke.leader.id} name={duke.leader.name} />
|
||||
<div className="min-w-0 flex-1 basis-40">
|
||||
<p className="m-0 text-xs font-semibold tracking-[0.16em] text-[#d5b888] uppercase">
|
||||
Ecaz · shared leader · strength 6
|
||||
</p>
|
||||
<h3 className="mt-1 mb-2 break-words font-serif text-xl text-[#fff0d4]">
|
||||
Duke Prad Vidal
|
||||
</h3>
|
||||
<p className="m-0 break-words text-sm">{status}</p>
|
||||
<p className="mt-1 mb-0 text-sm text-[#cbbbcc]">No Traitor Card</p>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
aria-label="Inspect Duke Prad Vidal"
|
||||
>
|
||||
<Eye aria-hidden="true" /> Inspect Duke
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
|
||||
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#9c815c] bg-[#19161d] text-[#eee7de] shadow-2xl outline-none motion-reduce:animate-none motion-reduce:transition-none">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#4c3b51] px-4 py-3">
|
||||
<span className="text-xs font-semibold tracking-[0.16em] text-[#dec18b] uppercase">
|
||||
Shared leader inspection
|
||||
</span>
|
||||
<DialogClose
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<X aria-hidden="true" /> Close
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||||
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<article className="min-w-0 rounded-xl border border-[#a08967] bg-gradient-to-br from-[#4a334e] to-[#221d28] p-5">
|
||||
<p className="m-0 text-sm tracking-wider text-[#e5cea6] uppercase">
|
||||
House Ecaz
|
||||
</p>
|
||||
<VidalPortrait
|
||||
id={duke.leader.id}
|
||||
name={duke.leader.name}
|
||||
large
|
||||
/>
|
||||
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff1d5]">
|
||||
{DUKE_VIDAL_RULES.name}
|
||||
</DialogTitle>
|
||||
<dl className="my-5 space-y-3 border-y border-[#a08967]/50 py-4">
|
||||
<div>
|
||||
<dt className="text-sm text-[#d6c2cc]">
|
||||
Leader strength
|
||||
</dt>
|
||||
<dd className="m-0 font-serif text-4xl">6</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-[#d6c2cc]">
|
||||
Current state
|
||||
</dt>
|
||||
<dd className="m-0 break-words text-base">{status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mb-0 text-sm leading-6">
|
||||
One shared disc. No Traitor Card.
|
||||
</p>
|
||||
</article>
|
||||
<div className="min-w-0 space-y-5">
|
||||
<DialogDescription className="m-0 text-base leading-7 text-[#ece0ce]">
|
||||
{DUKE_VIDAL_RULES.summary}
|
||||
</DialogDescription>
|
||||
{DUKE_VIDAL_RULES.gameplay.map((paragraph) => (
|
||||
<p
|
||||
key={paragraph}
|
||||
className="m-0 text-base leading-7 text-[#ddd4df]"
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
<p className="m-0 rounded-lg border border-[#675071] bg-[#2c2232] p-4 text-sm leading-6 text-[#e5d3df]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg
|
||||
viewBox="0 0 120 120"
|
||||
className={large ? 'mx-auto my-5 size-40 max-w-full' : 'size-14 shrink-0'}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="55"
|
||||
fill="#182725"
|
||||
stroke={identity.color}
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<circle cx="60" cy="60" r="45" fill="none" stroke="#dac594" />
|
||||
<path
|
||||
d="M60 9l5 10-5 10-5-10zM60 91l5 10-5 10-5-10zM9 60l10-5 10 5-10 5zM91 60l10-5 10 5-10 5z"
|
||||
fill="#dac594"
|
||||
/>
|
||||
<text
|
||||
x="60"
|
||||
y="75"
|
||||
textAnchor="middle"
|
||||
fill="#f2dfb9"
|
||||
fontFamily="Georgia,serif"
|
||||
fontSize="40"
|
||||
>
|
||||
{identity.sigil}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
/** 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 (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto min-h-11 w-full min-w-0 justify-start gap-3 whitespace-normal px-3 py-3 text-left motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
aria-label={`Inspect ${guide.name} Ambassador: ${status}`}
|
||||
>
|
||||
<AmbassadorArt token={token} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-serif text-base">{guide.name}</span>
|
||||
<span className="block text-xs leading-5">{status}</span>
|
||||
</span>
|
||||
<Eye aria-hidden="true" className="size-4 shrink-0" />
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none" />
|
||||
<DialogPrimitive.Popup className="fixed left-1/2 top-1/2 z-50 max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-[#8c815c] bg-[#17231f] p-5 text-[#e8e9dc] shadow-2xl outline-none sm:p-7">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<DialogTitle className="m-0 font-serif text-2xl text-[#efd9a8]">
|
||||
{guide.name} Ambassador
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-2 text-sm leading-6">
|
||||
Gameplay guide · {status}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-11 min-w-11 shrink-0 motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
aria-label="Close Ambassador inspection"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
<AmbassadorArt token={token} large />
|
||||
<p className="text-base leading-7">{guide.gameplay}</p>
|
||||
<h3 className="mt-6 font-serif text-xl text-[#efd9a8]">
|
||||
Using Ambassadors
|
||||
</h3>
|
||||
{AMBASSADOR_COMMON_GUIDANCE.map((paragraph) => (
|
||||
<p key={paragraph} className="text-sm leading-6">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
<p className="mt-5 border-t border-[#68705a] pt-4 text-sm leading-6">
|
||||
{AMBASSADOR_COVERAGE}
|
||||
</p>
|
||||
<p className="text-xs leading-5">
|
||||
Original illustration and gameplay explanation.
|
||||
</p>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<details className="notice min-w-0">
|
||||
<summary className="min-h-11 cursor-pointer py-2 leading-7 focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<span className="font-serif text-xl">Ecaz Ambassadors</span>{' '}
|
||||
<span className="text-sm">
|
||||
· {supplyCount} in supply · {placedCount} placed · Inspect tokens
|
||||
</span>
|
||||
</summary>
|
||||
<p className="text-sm leading-6">
|
||||
Inspect any token to read its effect and current location.
|
||||
</p>
|
||||
{zones.map((zone) => {
|
||||
const tokens = game.ambassadors!.tokens.filter(
|
||||
(token) => token.zone === zone,
|
||||
);
|
||||
if (!tokens.length) return null;
|
||||
return (
|
||||
<div key={zone} className="mt-4">
|
||||
<h4 className="mb-2 text-sm font-semibold">
|
||||
{names[zone]} · {tokens.length}
|
||||
</h4>
|
||||
<div className="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
{tokens.map((token) => (
|
||||
<AmbassadorInspector token={token} key={token.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="mt-4 text-sm leading-6">{AMBASSADOR_COVERAGE}</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
className="notice flex min-w-0 flex-col gap-3"
|
||||
aria-label="Place an Ecaz Ambassador"
|
||||
>
|
||||
<h3 className="m-0 font-serif text-xl">Place an Ambassador</h3>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Next placement: <strong>{state.nextCost} spice</strong> · Your available
|
||||
spice: {me.spice ?? 0}. Each placement this turn increases the next cost
|
||||
by one.
|
||||
</p>
|
||||
<label htmlFor={`${id}-token`}>
|
||||
Ambassador
|
||||
<select
|
||||
id={`${id}-token`}
|
||||
className="min-h-11 w-full"
|
||||
value={token?.id ?? ''}
|
||||
disabled={busy || state.blocked}
|
||||
onChange={(event) => setSelectedToken(event.target.value)}
|
||||
>
|
||||
<option value="">Choose from your supply</option>
|
||||
{supply.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{AMBASSADOR_REFERENCE[candidate.effect].name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{token && (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
{AMBASSADOR_REFERENCE[token.effect].gameplay}
|
||||
</p>
|
||||
<AmbassadorInspector token={token} />
|
||||
</>
|
||||
)}
|
||||
<label htmlFor={`${id}-territory`}>
|
||||
Stronghold
|
||||
<select
|
||||
id={`${id}-territory`}
|
||||
className="min-h-11 w-full"
|
||||
value={destination?.id ?? ''}
|
||||
disabled={busy || state.blocked}
|
||||
onChange={(event) => setSelectedTerritory(event.target.value)}
|
||||
>
|
||||
<option value="">Choose a stronghold outside the storm</option>
|
||||
{destinations.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{state.blocked && (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Ambassador placement is blocked for this turn.
|
||||
</p>
|
||||
)}
|
||||
{!supply.length && (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
No Ambassador is available in your supply.
|
||||
</p>
|
||||
)}
|
||||
{!destinations.length && (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
No ordinary stronghold is currently available for an Ambassador.
|
||||
</p>
|
||||
)}
|
||||
{!affordable && (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
You need {state.nextCost - (me.spice ?? 0)} more spice for this
|
||||
placement.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled && token && destination)
|
||||
act({
|
||||
type: 'decision',
|
||||
token: token.id,
|
||||
territory: destination.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Place Ambassador · {state.nextCost} spice
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Finish Ambassador placement
|
||||
</Button>
|
||||
</div>
|
||||
<p className="m-0 text-sm leading-6">{AMBASSADOR_COVERAGE}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>([]);
|
||||
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<Action, 'type'>) => {
|
||||
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 (
|
||||
<section
|
||||
className="notice flex min-w-0 flex-col gap-4"
|
||||
aria-label="Ecaz Ambassador entry decision"
|
||||
>
|
||||
<h3 className="m-0 font-serif text-xl">
|
||||
{guide.name} Ambassador · {territory(entry.territory).name}
|
||||
</h3>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
{game.players.find((p) => p.id === entry.entrant)?.name} entered this
|
||||
stronghold. {guide.gameplay}
|
||||
</p>
|
||||
{token && <AmbassadorInspector token={token} />}
|
||||
{entry.stage === 'offer' ? (
|
||||
<>
|
||||
{entry.effect === 'ecaz' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Acquire Duke Vidal for Ecaz. The Ambassador returns to your
|
||||
supply. Ecaz keeps Duke until battle use or until Moritani takes
|
||||
him.
|
||||
</p>
|
||||
{duke && (
|
||||
<LeaderInspector
|
||||
identity={{
|
||||
id: duke.id,
|
||||
name: duke.name,
|
||||
factionName: faction(duke.faction).name,
|
||||
strength: leaderStrengthLabel(duke),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy || !!dukeBlock}
|
||||
aria-describedby={dukeBlock ? `${id}-duke-block` : undefined}
|
||||
onClick={() => {
|
||||
if (!dukeBlock)
|
||||
send({
|
||||
trigger: true,
|
||||
beneficiary: entry.owner,
|
||||
choice: 'duke',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Acquire Duke Vidal for Ecaz
|
||||
</Button>
|
||||
{dukeBlock && (
|
||||
<p id={`${id}-duke-block`} className="m-0 text-sm leading-6">
|
||||
{dukeBlock}
|
||||
</p>
|
||||
)}
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Alliance proposals, acquisition for an ally and Duke loans
|
||||
through this Ambassador remain unfinished.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Trigger the Ambassador for yourself or an eligible ally, or
|
||||
leave it in place. Triggering commits the token before the
|
||||
beneficiary resolves its effect.
|
||||
</p>
|
||||
{entry.effect === 'richese' && (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
{entry.beneficiaries.map((option) => (
|
||||
<div key={option.player} className="flex flex-col gap-2">
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy || !!option.blocked}
|
||||
onClick={() =>
|
||||
send({ trigger: true, beneficiary: option.player })
|
||||
}
|
||||
>
|
||||
Trigger for{' '}
|
||||
{option.player === game.me
|
||||
? 'yourself'
|
||||
: game.players.find((p) => p.id === option.player)?.name}
|
||||
</Button>
|
||||
{option.blocked && (
|
||||
<p className="m-0 text-sm leading-6">{option.blocked}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className={buttonClass}
|
||||
disabled={busy}
|
||||
onClick={() => send({ decline: true })}
|
||||
>
|
||||
Leave Ambassador in place
|
||||
</Button>
|
||||
</>
|
||||
) : entry.stage === 'copy' ? (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Choose an effect outside the original supply for this group. The
|
||||
Bene Gesserit token has already been permanently removed.
|
||||
</p>
|
||||
{entry.copies.map((option) => {
|
||||
const copied = AMBASSADOR_REFERENCE[option.effect];
|
||||
const face = game.ambassadors?.tokens.find(
|
||||
(t) => t.effect === option.effect,
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={option.effect}
|
||||
className="flex min-w-0 flex-col gap-2 rounded-lg border border-[#485542] p-3"
|
||||
>
|
||||
<h4 className="m-0 font-serif text-lg">{copied.name}</h4>
|
||||
<p className="m-0 text-sm leading-6">{copied.gameplay}</p>
|
||||
{option.effect === 'richese' && (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
{face && <AmbassadorInspector token={face} />}
|
||||
{option.blocked && (
|
||||
<p className="m-0 text-sm leading-6">{option.blocked}</p>
|
||||
)}
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy || !!option.blocked}
|
||||
onClick={() => send({ effect: option.effect })}
|
||||
>
|
||||
Use {copied.name} effect
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : entry.stage === 'ship' ? (
|
||||
<AmbassadorShipment
|
||||
key={entry.event}
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
) : entry.stage === 'move' ? (
|
||||
<AmbassadorMovement
|
||||
key={entry.event}
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
{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.'}
|
||||
</p>
|
||||
<fieldset
|
||||
className="flex min-w-0 flex-col gap-3 border-0 p-0"
|
||||
disabled={busy}
|
||||
>
|
||||
<legend className="mb-2 text-sm font-semibold">
|
||||
Your cards available for this effect
|
||||
</legend>
|
||||
{choices.map(({ card, blocked }) => (
|
||||
<article
|
||||
key={card.id}
|
||||
className="flex min-w-0 flex-col gap-2 rounded-lg border border-[#485542] p-3"
|
||||
>
|
||||
<label
|
||||
htmlFor={`${id}-${card.id}`}
|
||||
className="flex min-h-11 cursor-pointer items-center gap-3 text-base"
|
||||
>
|
||||
<input
|
||||
id={`${id}-${card.id}`}
|
||||
type={entry.effect === 'ixians' ? 'radio' : 'checkbox'}
|
||||
name={`${id}-discard`}
|
||||
checked={!blocked && selected.includes(card.id)}
|
||||
disabled={busy || !!blocked}
|
||||
onChange={(event) =>
|
||||
setSelected((current) =>
|
||||
entry.effect === 'ixians'
|
||||
? [card.id]
|
||||
: event.target.checked
|
||||
? [...current, card.id]
|
||||
: current.filter((value) => value !== card.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{card.name}
|
||||
</label>
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
{blocked && <p className="m-0 text-sm leading-6">{blocked}</p>}
|
||||
</article>
|
||||
))}
|
||||
</fieldset>
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy || !legalSelection}
|
||||
onClick={() => {
|
||||
if (legalSelection)
|
||||
send({ cards: chosen.map((choice) => choice.card.id) });
|
||||
}}
|
||||
>
|
||||
{entry.effect === 'choam'
|
||||
? `Discard ${chosen.length} cards · gain ${chosen.length * 3} spice`
|
||||
: 'Discard selected card and draw replacement'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<p className="m-0 text-xs leading-5">{AMBASSADOR_COVERAGE}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<section
|
||||
className="notice flex min-w-0 flex-col gap-3"
|
||||
aria-label="Your private Ambassador inspection history"
|
||||
>
|
||||
<h3 className="m-0 font-serif text-xl">Private Ambassador inspections</h3>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
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.
|
||||
</p>
|
||||
{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 (
|
||||
<details
|
||||
key={snapshot.event}
|
||||
open={index === snapshots.length - 1}
|
||||
className="min-w-0 rounded-lg border border-[#485542] p-3"
|
||||
>
|
||||
<summary className="min-h-11 cursor-pointer py-2 text-base focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
Turn {snapshot.turn} · {target?.name ?? 'Player'} ·{' '}
|
||||
{snapshot.effect === 'atreides'
|
||||
? 'Treachery hand'
|
||||
: target?.faction === 'tleilaxu'
|
||||
? 'Face Dancer identity'
|
||||
: 'Traitor identity'}
|
||||
</summary>
|
||||
{snapshot.effect === 'atreides' ? (
|
||||
<div className="grid min-w-0 gap-3 pt-3 sm:grid-cols-2">
|
||||
{!snapshot.cards.length && (
|
||||
<p className="text-sm leading-6">
|
||||
The inspected hand was empty.
|
||||
</p>
|
||||
)}
|
||||
{snapshot.cards.map((card) => (
|
||||
<article
|
||||
key={card.id}
|
||||
className="flex min-w-0 flex-col gap-2 rounded-lg border border-[#485542] p-3"
|
||||
>
|
||||
<h4 className="m-0 font-serif text-lg">{card.name}</h4>
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : cheapHero || leader ? (
|
||||
<div className="pt-3">
|
||||
<LeaderInspector
|
||||
kind={
|
||||
target?.faction === 'tleilaxu' ? 'faceDancer' : 'traitor'
|
||||
}
|
||||
identity={
|
||||
cheapHero
|
||||
? {
|
||||
name: 'Cheap Hero / Heroine',
|
||||
factionName: 'Any faction',
|
||||
cheapHero: true,
|
||||
strength: 0,
|
||||
}
|
||||
: {
|
||||
id: leader!.id,
|
||||
name: leader!.name,
|
||||
factionName: faction(leader!.faction).name,
|
||||
strength: leaderStrengthLabel(leader!),
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm leading-6">
|
||||
Recorded identity: {snapshot.traitor ?? 'None'}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section
|
||||
className="notice flex min-w-0 flex-col gap-4"
|
||||
aria-labelledby={`${id}-title`}
|
||||
>
|
||||
<h3 id={`${id}-title`} className="m-0 font-serif text-xl">
|
||||
Share spice from {territory(lot.territory).name}
|
||||
</h3>
|
||||
<p id={`${id}-pool`} className="m-0 text-sm leading-6">
|
||||
Divide this shared pool of <strong>{lot.amount} spice</strong> between{' '}
|
||||
{ecazName} (Ecaz{me === lot.ecaz ? ', you' : ''}) and {allyName} (ally
|
||||
{me === lot.ally ? ', you' : ''}). This choice applies only to the
|
||||
shared spice collected here.
|
||||
</p>
|
||||
{offer && (
|
||||
<output className="m-0 text-sm leading-6">
|
||||
{name(offer.by)} proposes {offer.ecazShare} spice for {ecazName} and{' '}
|
||||
{lot.amount - offer.ecazShare} for {allyName}. Accept this split or
|
||||
propose another.
|
||||
</output>
|
||||
)}
|
||||
<form
|
||||
className="flex min-w-0 flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (busy || !valid) return;
|
||||
send({
|
||||
type: 'decision',
|
||||
event,
|
||||
allocation: { kind: 'propose', ecazShare },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-share`}>
|
||||
Ecaz share · {ecazName}
|
||||
<Input
|
||||
id={`${id}-share`}
|
||||
className="min-h-11"
|
||||
type="number"
|
||||
min={0}
|
||||
max={lot.amount}
|
||||
step={1}
|
||||
value={draft}
|
||||
disabled={busy}
|
||||
aria-invalid={!valid}
|
||||
aria-describedby={`${id}-shares`}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p
|
||||
id={`${id}-shares`}
|
||||
className="m-0 text-sm leading-6"
|
||||
aria-live="polite"
|
||||
>
|
||||
{valid
|
||||
? `Proposed split: ${ecazShare} for ${ecazName}, ${lot.amount - ecazShare} for ${allyName}.`
|
||||
: `Enter a whole number from 0 to ${lot.amount}.`}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className={buttonClass}
|
||||
disabled={busy || !valid}
|
||||
>
|
||||
{offer ? 'Send counterproposal' : 'Propose split'}
|
||||
</Button>
|
||||
{offer && (
|
||||
<Button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
variant="outline"
|
||||
disabled={busy || offer.by === me}
|
||||
onClick={() =>
|
||||
send({
|
||||
type: 'decision',
|
||||
event,
|
||||
allocation: { kind: 'accept' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Accept proposed split
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Equal fallback: {equalEcaz} spice for {ecazName}, {equalAlly} for{' '}
|
||||
{allyName}. The ally receives any odd remainder.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
send({ type: 'decision', event, allocation: { kind: 'equal' } })
|
||||
}
|
||||
>
|
||||
Use equal split
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
import { useId } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
export function EliteCount({
|
||||
value,
|
||||
onChange,
|
||||
max,
|
||||
label = 'Elite forces included',
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
max: number;
|
||||
label?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<label htmlFor={id}>
|
||||
{label}
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min={0}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, number>>({
|
||||
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 (
|
||||
<>
|
||||
<h2>Face Dancer opportunity</h2>
|
||||
<p>
|
||||
{g.players.find((p) => p.id === d.winner)?.name} won in{' '}
|
||||
{territory(d.territory).name}. Their battle rewards remain theirs.
|
||||
</p>
|
||||
{held ? (
|
||||
<>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{Object.entries({ reserves: me.reserves, ...me.forces }).map(
|
||||
([key, available]) => (
|
||||
<label key={key} htmlFor={`face-source-${key}`}>
|
||||
{key === 'reserves'
|
||||
? 'Reserves'
|
||||
: `${territory(splitLocation(key).territory).name} · sector ${splitLocation(key).sector}`}{' '}
|
||||
({available} available)
|
||||
<Input
|
||||
id={`face-source-${key}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={Math.min(available, maximum)}
|
||||
value={sources[key] ?? 0}
|
||||
onChange={(e) =>
|
||||
setSources({ ...sources, [key]: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
<label htmlFor="face-sector">
|
||||
Replacement sector
|
||||
<select
|
||||
id="face-sector"
|
||||
value={sector}
|
||||
onChange={(e) => setSector(Number(e.target.value))}
|
||||
>
|
||||
{territory(d.territory).sectors.map((s) => (
|
||||
<option value={s} key={s}>
|
||||
{s === 0 ? 'Polar Sink' : `Sector ${s}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p>
|
||||
{total} / {maximum} replacement forces selected
|
||||
</p>
|
||||
<Button
|
||||
disabled={
|
||||
busy ||
|
||||
total > maximum ||
|
||||
Object.values(sources).some((n) => n < 0 || !Number.isInteger(n))
|
||||
}
|
||||
onClick={() =>
|
||||
act({ type: 'decision', reveal: true, sources, sector })
|
||||
}
|
||||
>
|
||||
Reveal Face Dancer
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p>You have no unrevealed Face Dancer matching the winning leader.</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', reveal: false })}
|
||||
>
|
||||
Decline Face Dancer
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
className="help-tip"
|
||||
aria-label={`Help: ${topic}`}
|
||||
openOnHover
|
||||
delay={200}
|
||||
>
|
||||
<CircleHelp size={14} aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="game-help-tooltip motion-reduce:animate-none"
|
||||
aria-label={`Help: ${topic}`}
|
||||
side="top"
|
||||
>
|
||||
{HELP[topic]}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, number>>({});
|
||||
const [recover, setRecover] = useState<Record<string, number>>({});
|
||||
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<string, number>) =>
|
||||
Object.values(value).reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<h3>Suboids to sacrifice</h3>
|
||||
{available.map(([key, max]) => (
|
||||
<label key={key}>
|
||||
Sector {splitLocation(key).sector} · {max} suboids available
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={max}
|
||||
value={sources[key] ?? 0}
|
||||
onChange={(e) =>
|
||||
setSources({ ...sources, [key]: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<h3>Cyborgs to retain</h3>
|
||||
{Object.entries(d.losses).map(([key, max]) => (
|
||||
<label key={key}>
|
||||
Sector {splitLocation(key).sector} · {max} cyborgs lost
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={max}
|
||||
value={recover[key] ?? 0}
|
||||
onChange={(e) =>
|
||||
setRecover({ ...recover, [key]: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<p>
|
||||
{total(sources)} suboids for {total(recover)} cyborgs
|
||||
</p>
|
||||
<Button
|
||||
disabled={busy || !total(sources) || total(sources) !== total(recover)}
|
||||
onClick={() => act({ type: 'decision', sources, recover })}
|
||||
>
|
||||
Confirm substitution
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Keep these casualties
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<p>
|
||||
You purchased{' '}
|
||||
<strong>
|
||||
{g.ixPurchased?.name ?? 'a card that is no longer in your hand'}
|
||||
</strong>
|
||||
. Your Ixian alliance lets you keep it or discard it for the top card
|
||||
of the deck. The replacement stays private.{' '}
|
||||
<HelpTip topic="ixTechnology" />
|
||||
</p>
|
||||
{g.ixPurchased && <CardInspector card={g.ixPurchased} />}
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', accept: false })}
|
||||
>
|
||||
Keep purchase
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || !g.ixPurchased}
|
||||
onClick={() => act({ type: 'decision', accept: true })}
|
||||
>
|
||||
Request replacement
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{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.'}{' '}
|
||||
<HelpTip topic="ixTechnology" />
|
||||
</p>
|
||||
{kind === 'ixAuction' && (
|
||||
<label>
|
||||
Return chosen card to
|
||||
<select
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value)}
|
||||
>
|
||||
<option value="bottom">Bottom of deck</option>
|
||||
<option value="top">Top of deck</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="card-choices grid min-w-0 gap-3">
|
||||
{cards?.map((c) => (
|
||||
<article
|
||||
key={c.id}
|
||||
className="flex min-w-0 flex-col gap-3 rounded-lg border border-[#454b3c] bg-[#1d221b] p-3"
|
||||
>
|
||||
<h3 className="m-0 break-words text-base font-semibold text-[#efd9a8]">
|
||||
{c.name}
|
||||
</h3>
|
||||
<CardInspector card={c} />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="game-action min-h-11"
|
||||
aria-label={`${kind === 'ixAuction' ? 'Return card' : kind === 'ixSetup' ? 'Choose starting card' : 'Offer this card'}: ${c.name}`}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'decision',
|
||||
card: c.id,
|
||||
...(kind === 'ixAuction' ? { position } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{kind === 'ixAuction'
|
||||
? 'Return card'
|
||||
: kind === 'ixSetup'
|
||||
? 'Choose starting card'
|
||||
: 'Offer this card'}
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{kind === 'ixTechnology' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Save technology for a later card
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<details className="my-4 min-w-0">
|
||||
<summary className="min-h-11 cursor-pointer py-3">
|
||||
Juice of Sapho: change turn order
|
||||
</summary>
|
||||
<div className="flex min-w-0 flex-col gap-4 py-3">
|
||||
<p>
|
||||
Choose one available order change, then discard Juice of Sapho. This
|
||||
does not grant an extra bid, shipment or movement.
|
||||
</p>
|
||||
<CardInspector card={card} />
|
||||
{options.length ? (
|
||||
options.map((option) => {
|
||||
const once = option.scope === 'onceAround';
|
||||
return (
|
||||
<div
|
||||
key={`${option.scope}-${option.event}-${option.mode}`}
|
||||
className="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<p>
|
||||
{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' : ''}.`}
|
||||
</p>
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!busy) act({ type: 'card', card: card.id, ...option });
|
||||
}}
|
||||
>
|
||||
{once ? 'Bid' : 'Take your movement turn'} {option.mode} and
|
||||
discard Juice of Sapho
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="notice" id={`${id}-reason`}>
|
||||
{unavailableReason(game)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
Battle aggressor and other phase or auction modes are still
|
||||
unfinished.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
className="mx-auto my-5 w-40 max-w-full sm:w-48"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={gradient} cx="38%" cy="30%" r="75%">
|
||||
<stop offset="0" stopColor={traitor ? '#563e38' : '#414c3b'} />
|
||||
<stop offset="1" stopColor="#151a15" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="92"
|
||||
fill={`url(#${gradient})`}
|
||||
stroke="#c3a365"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="84"
|
||||
fill="none"
|
||||
stroke="#bfa46e"
|
||||
strokeOpacity="0.55"
|
||||
/>
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="66"
|
||||
fill="none"
|
||||
stroke="#bfa46e"
|
||||
strokeOpacity="0.25"
|
||||
/>
|
||||
{Array.from({ length: 16 }, (_, index) => (
|
||||
<path
|
||||
key={index}
|
||||
d="M 100 12 V 20"
|
||||
stroke="#ddc48c"
|
||||
strokeWidth={index % 4 === 0 ? 3 : 1}
|
||||
transform={`rotate(${index * 22.5} 100 100)`}
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
d="M 100 40 L 139 100 L 100 160 L 61 100 Z"
|
||||
fill="none"
|
||||
stroke="#e3c787"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d={
|
||||
traitor
|
||||
? 'M 100 56 L 108 103 L 100 119 L 92 103 Z M 80 120 H 120 M 100 120 V 144'
|
||||
: 'M 77 76 L 88 93 L 100 68 L 112 93 L 123 76 L 116 112 H 84 Z M 85 125 H 115'
|
||||
}
|
||||
fill="none"
|
||||
stroke="#f1dba8"
|
||||
strokeWidth="3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="36" cy="100" r="3" fill="#c3a365" />
|
||||
<circle cx="164" cy="100" r="3" fill="#c3a365" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
aria-label={`Inspect ${label}: ${identity.name}`}
|
||||
>
|
||||
<Eye aria-hidden="true" />
|
||||
Inspect {label}
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
|
||||
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-reduce:animate-none motion-reduce:transition-none sm:max-h-[calc(100dvh-3rem)] sm:w-[calc(100%-3rem)]">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
|
||||
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
|
||||
{label} inspection
|
||||
</span>
|
||||
<DialogClose
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
Close
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||||
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<article
|
||||
className={`min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br ${faceDancer ? 'from-[#43374b] via-[#2e2835]' : traitor ? 'from-[#482e2b] via-[#2e2721]' : 'from-[#344432] via-[#292f25]'} to-[#171b17] p-5 shadow-lg sm:p-6`}
|
||||
>
|
||||
<p className="m-0 break-words text-sm font-semibold tracking-wider text-[#f1d79f] uppercase">
|
||||
{identity.factionName}
|
||||
</p>
|
||||
<LeaderPortrait
|
||||
identityId={cheapHero ? undefined : identity.id}
|
||||
name={identity.name}
|
||||
className="mx-auto my-5 size-40 max-w-full sm:size-48"
|
||||
sizes="(min-width: 640px) 192px, 160px"
|
||||
fallback={<IdentityMedallion traitor={traitor} />}
|
||||
/>
|
||||
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
|
||||
{identity.name}
|
||||
</DialogTitle>
|
||||
<dl className="mt-5 border-y border-[#b59a64]/40 py-4">
|
||||
<dt className="text-sm font-medium text-[#d6c9ad]">
|
||||
Leader strength
|
||||
</dt>
|
||||
<dd className="m-0 mt-1 break-words font-serif text-5xl leading-tight text-[#f6e4b9]">
|
||||
{identity.strength}
|
||||
</dd>
|
||||
</dl>
|
||||
<p className="mt-4 mb-0 text-xs tracking-wider text-[#cebea0] uppercase">
|
||||
{faceDancer
|
||||
? 'Face Dancer identity'
|
||||
: traitor
|
||||
? 'Traitor identity'
|
||||
: 'Leader identity'}{' '}
|
||||
· gameplay guide
|
||||
</p>
|
||||
</article>
|
||||
<section
|
||||
className="flex min-w-0 flex-col gap-5"
|
||||
aria-label={`${faceDancer ? 'Face Dancer' : traitor ? 'Traitor' : 'Leader'} guidance`}
|
||||
>
|
||||
<DialogDescription className="m-0 text-base leading-7 text-[#eee5d2]">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
{identity.id === 'choam-auditor' && (
|
||||
<div className="space-y-4 text-base leading-7 text-[#e0e4d8]">
|
||||
<p>
|
||||
Advanced CHOAM has this additional strength-2 leader and
|
||||
its matching traitor identity. It remains separate from
|
||||
the five ordinary leaders.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{faceDancer ? (
|
||||
<>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
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.
|
||||
</p>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
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.
|
||||
</p>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
Kwisatz Haderach does not protect the accompanying leader
|
||||
from Face Dancing. Karama cannot cancel this reveal or the
|
||||
replacement forces.
|
||||
</p>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
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.
|
||||
</p>
|
||||
{cheapHero && (
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : traitor ? (
|
||||
<>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
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.
|
||||
</p>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
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.
|
||||
</p>
|
||||
{!cheapHero && (
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
Keeping your own leader as a traitor can prevent another
|
||||
player from holding that traitor.
|
||||
</p>
|
||||
)}
|
||||
{cheapHero && (
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
{HELP.leader}
|
||||
</p>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
Inspection does not choose or commit a leader. Close this
|
||||
view to review the available leaders in your battle plan.
|
||||
</p>
|
||||
{cheapHero && (
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
The hero treachery card is discarded after use. The
|
||||
separate Cheap Hero traitor identity can match either
|
||||
hero.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{identity.strength === 'X' && (
|
||||
<p className="m-0 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4 text-base leading-7 text-[#e0e4d8]">
|
||||
X marks variable strength. Use the faction’s battle rule to
|
||||
determine its value against the opposing leader.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const art = leaderArt(identityId, name) ?? specialLeaderArt(identityId, name);
|
||||
if (!art || failedSource === art.src) return fallback;
|
||||
return (
|
||||
<span className={`relative block shrink-0 ${className}`}>
|
||||
<span className="absolute inset-0 overflow-hidden rounded-full border-2 border-[#c5a66d] bg-[#1b201b] shadow-[inset_0_0_0_4px_#151a15,0_5px_15px_#0005]">
|
||||
<Image
|
||||
src={art.src}
|
||||
alt=""
|
||||
width={1024}
|
||||
height={1024}
|
||||
sizes={sizes}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setFailedSource(art.src)}
|
||||
className="h-full w-full object-cover"
|
||||
style={{ objectPosition: art.objectPosition }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-1 rounded-full border border-[#e6ce99]/40"
|
||||
/>
|
||||
</span>
|
||||
{badge !== undefined && (
|
||||
<span className="absolute -right-1 -bottom-1 flex min-h-8 min-w-8 items-center justify-center rounded-full border border-[#dfc283] bg-[#171b17] px-1.5 font-serif text-lg leading-none text-[#fff0c5] shadow-md">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>(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 (
|
||||
<div className="mobile-route">
|
||||
<p>
|
||||
{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.`}{' '}
|
||||
<HelpTip topic="mobileStronghold" />
|
||||
</p>
|
||||
{!placement && pointer && splitLocation(pointer).sector === g.storm && (
|
||||
<p className="fine">
|
||||
The pointing sector is in storm. Keep the current position until the
|
||||
storm clears.
|
||||
</p>
|
||||
)}
|
||||
{!placement && (
|
||||
<>
|
||||
<ol>
|
||||
{route.map((key) => (
|
||||
<li key={key}>
|
||||
{label(key)}
|
||||
{g.spice[key] ? ` · ${g.spice[key]} spice` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="fine">
|
||||
{mobileRouteDistance(route)} / {max} territories ·{' '}
|
||||
{collect ? preview : 0} spice to collect
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
{placement ? 'Pointing sector' : 'Next sector'}
|
||||
<select
|
||||
aria-label={placement ? 'Pointing sector' : 'Next route sector'}
|
||||
value={placement ? destination : ''}
|
||||
onChange={(e) => {
|
||||
if (placement) setDestination(e.target.value);
|
||||
else if (e.target.value) setRoute([...route, e.target.value]);
|
||||
}}
|
||||
>
|
||||
{!placement && <option value="">Choose next sector…</option>}
|
||||
{choices.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{label(key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{!placement && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || route.length < 2}
|
||||
onClick={() => setRoute(route.slice(0, -1))}
|
||||
>
|
||||
Undo last step
|
||||
</Button>
|
||||
<label className="decision-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={collect}
|
||||
onChange={(e) => setCollect(e.target.checked)}
|
||||
/>
|
||||
Collect spice along the route
|
||||
</label>
|
||||
{!valid && route.length > 1 && (
|
||||
<p className="fine">Finish in a non-stronghold territory.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || !valid}
|
||||
onClick={() =>
|
||||
act(
|
||||
placement
|
||||
? { type: 'decision', location: destination }
|
||||
: card
|
||||
? { type: 'card', mode: 'special', card, route, collect }
|
||||
: { type: 'decision', route, collect },
|
||||
)
|
||||
}
|
||||
>
|
||||
{placement
|
||||
? 'Place stronghold'
|
||||
: card
|
||||
? 'Spend Karama and relocate'
|
||||
: 'Declare stronghold route'}
|
||||
</Button>
|
||||
{!placement && !card && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Keep current position
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<section
|
||||
className="min-w-0 space-y-4"
|
||||
aria-label="Moritani allied card retention"
|
||||
>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p className="fine">
|
||||
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.
|
||||
</p>
|
||||
<fieldset
|
||||
className="m-0 min-w-0 space-y-3 border-0 p-0"
|
||||
disabled={disabled}
|
||||
>
|
||||
<legend className="mb-3 font-semibold">
|
||||
Choose one card to retain
|
||||
</legend>
|
||||
{cards.map((card) => (
|
||||
<article
|
||||
key={card.id}
|
||||
className={`treachery-card card-${card.kind} min-w-0`}
|
||||
>
|
||||
<label
|
||||
htmlFor={`${groupId}-${card.id}`}
|
||||
className="flex min-h-11 cursor-pointer items-center gap-3 break-words"
|
||||
>
|
||||
<input
|
||||
id={`${groupId}-${card.id}`}
|
||||
type="radio"
|
||||
name={groupId}
|
||||
value={card.id}
|
||||
checked={selectedCard?.id === card.id}
|
||||
onChange={() => setSelected(card.id)}
|
||||
className="size-5 shrink-0 accent-[#d7b673]"
|
||||
/>
|
||||
<span className="min-w-0 font-semibold">{card.name}</span>
|
||||
</label>
|
||||
<p className="eyebrow">{battleCardLabel(card.kind)}</p>
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
</article>
|
||||
))}
|
||||
</fieldset>
|
||||
{!cards.length && (
|
||||
<p className="fine">
|
||||
No eligible card remains in your hand. Decline to continue.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 w-full whitespace-normal"
|
||||
disabled={disabled || !selectedCard}
|
||||
onClick={() =>
|
||||
selectedCard && act({ type: 'decision', keep: selectedCard.id })
|
||||
}
|
||||
>
|
||||
Declare selected card
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 w-full whitespace-normal"
|
||||
disabled={disabled}
|
||||
onClick={() => act({ type: 'decision', keep: null })}
|
||||
>
|
||||
Decline retention
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<GameView['moritaniTerror']>['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 (
|
||||
<svg
|
||||
viewBox="0 0 120 120"
|
||||
className={large ? 'mx-auto my-6 size-40 max-w-full' : 'size-16 shrink-0'}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<circle cx="60" cy="63" r="55" fill="#080e0f" />
|
||||
<circle
|
||||
cx="60"
|
||||
cy="58"
|
||||
r="54"
|
||||
fill="#173a40"
|
||||
stroke="#d2b77b"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle cx="60" cy="58" r="46" fill="#10282d" stroke="#688987" />
|
||||
<path
|
||||
d="M 60 8 L 63 14 L 60 20 L 57 14 Z M 60 96 L 63 102 L 60 108 L 57 102 Z M 10 58 H 18 M 102 58 H 110"
|
||||
fill="#ddc690"
|
||||
stroke="#ddc690"
|
||||
/>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#f1dfb4"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{kind === 'assassination' ? (
|
||||
<>
|
||||
<path d="M 68 26 L 75 33 L 53 66 L 42 75 L 45 61 Z M 40 65 L 54 76 M 47 72 L 37 88" />
|
||||
<circle cx="77" cy="78" r="10" strokeWidth="1.5" />
|
||||
</>
|
||||
) : kind === 'atomics' ? (
|
||||
<>
|
||||
<circle cx="60" cy="58" r="9" fill="#b59a64" />
|
||||
<path d="M 60 25 V 40 M 60 76 V 91 M 27 58 H 42 M 78 58 H 93 M 36 34 L 47 45 M 73 71 L 84 82 M 36 82 L 47 71 M 73 45 L 84 34" />
|
||||
<circle cx="60" cy="58" r="25" strokeWidth="1" />
|
||||
</>
|
||||
) : kind === 'extortion' ? (
|
||||
<>
|
||||
<path d="M 38 38 H 82 V 82 H 38 Z M 47 38 V 30 H 73 V 38 M 38 50 H 82" />
|
||||
<path d="M 60 58 L 66 65 L 60 74 L 54 65 Z" fill="#b59a64" />
|
||||
</>
|
||||
) : kind === 'robbery' ? (
|
||||
<>
|
||||
<path d="M 33 46 Q 60 29 87 46 L 80 70 Q 60 83 40 70 Z" />
|
||||
<path d="M 43 50 L 54 54 L 44 59 M 77 50 L 66 54 L 76 59 M 55 70 H 65" />
|
||||
</>
|
||||
) : kind === 'sabotage' ? (
|
||||
<>
|
||||
<path d="M 53 28 H 67 L 70 39 L 81 41 L 87 54 L 79 63 L 81 76 L 67 84 L 58 76 L 45 79 L 36 65 L 43 55 L 40 41 L 52 38 Z" />
|
||||
<path
|
||||
d="M 68 39 L 53 57 L 67 59 L 52 79"
|
||||
stroke="#d59a80"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
</>
|
||||
) : kind === 'sneakAttack' ? (
|
||||
<>
|
||||
<path d="M 25 47 L 60 57 L 95 47 L 73 65 L 60 88 L 47 65 Z M 60 31 V 57 M 46 35 L 60 44 L 74 35" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M 60 27 L 83 42 V 61 Q 81 78 60 89 Q 39 78 37 61 V 42 Z" />
|
||||
<path d="M 49 59 V 52 A 11 11 0 0 1 71 52 V 59 M 47 59 H 73 V 75 H 47 Z" />
|
||||
<path d="M 60 64 V 69" />
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto min-h-11 min-w-0 flex-col gap-2 whitespace-normal border-[#66796a] bg-[#172624] px-2 py-3 text-[#f2dfb9] motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
aria-label={`Inspect ${title}: ${context}`}
|
||||
>
|
||||
<TerrorArt kind={kind} />
|
||||
<span className="break-words font-serif text-base leading-tight">
|
||||
{title}
|
||||
</span>
|
||||
<span className="break-words text-xs leading-5 text-[#c4cabc]">
|
||||
{context}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-[#dfc78e]">
|
||||
<Eye aria-hidden="true" /> Inspect
|
||||
</span>
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
|
||||
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
|
||||
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
|
||||
Moritani · Terror
|
||||
</span>
|
||||
<DialogClose
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<X aria-hidden="true" /> Close
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||||
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<article className="min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br from-[#294b4b] via-[#1c302d] to-[#171b17] p-5">
|
||||
<p className="m-0 text-xs font-semibold tracking-widest text-[#e0cd9f] uppercase">
|
||||
{context}
|
||||
</p>
|
||||
<TerrorArt kind={kind} large />
|
||||
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<p className="mt-5 mb-0 text-sm leading-6 text-[#d3daca]">
|
||||
{kind
|
||||
? 'Original token illustration · gameplay guide'
|
||||
: 'This placed token remains face down. Its identity is visible only to Moritani.'}
|
||||
</p>
|
||||
</article>
|
||||
<section
|
||||
className="min-w-0 space-y-4"
|
||||
aria-label="Terror token guidance"
|
||||
>
|
||||
<DialogDescription className="m-0 text-base leading-7 text-[#f1dfb4]">
|
||||
{definition?.summary ??
|
||||
'A hidden Terror token marks this stronghold. Inspecting it does not reveal its identity or trigger an effect.'}
|
||||
</DialogDescription>
|
||||
{(definition?.gameplay ?? TERROR_COMMON_GAMEPLAY).map(
|
||||
(paragraph) => (
|
||||
<p
|
||||
key={paragraph}
|
||||
className="m-0 text-sm leading-7 text-[#e0e4d8]"
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
<p className="border-t border-[#414536] pt-4 text-sm leading-6 text-[#c9c2ae]">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
className="min-w-0 rounded-xl border border-[#58624e] bg-[#161e19] p-3"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<h3 id={titleId} className="m-0 font-serif text-xl text-[#f1dfb4]">
|
||||
Moritani Terror
|
||||
</h3>
|
||||
<p className="mt-2 mb-3 text-sm leading-6 text-[#c4cabc]">
|
||||
{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.'}
|
||||
</p>
|
||||
{tokens.length ? (
|
||||
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{tokens.map((token) => (
|
||||
<TokenInspector key={token.id} token={token} owner={owner} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="m-0 text-sm text-[#c4cabc]">No public Terror tokens.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
|
||||
<h3 id={`${formId}-title`}>Place or relocate Terror</h3>
|
||||
<p className="text-sm leading-6">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<label htmlFor={`${formId}-token`} className="text-sm font-medium">
|
||||
Your Terror token
|
||||
</label>
|
||||
<select
|
||||
id={`${formId}-token`}
|
||||
className="min-h-11 w-full min-w-0 max-w-full"
|
||||
disabled={disabled || !tokens.length}
|
||||
value={token?.id ?? ''}
|
||||
onChange={(event) => setSelectedToken(event.target.value)}
|
||||
>
|
||||
{!tokens.length && <option value="">No available token</option>}
|
||||
{tokens.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{TERROR_DEFINITIONS[candidate.kind].name} ·{' '}
|
||||
{candidate.location
|
||||
? territory(candidate.location).name
|
||||
: 'Supply'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label htmlFor={`${formId}-territory`} className="text-sm font-medium">
|
||||
Destination stronghold
|
||||
</label>
|
||||
<select
|
||||
id={`${formId}-territory`}
|
||||
className="min-h-11 w-full min-w-0 max-w-full"
|
||||
disabled={disabled || !destinations.length || !token}
|
||||
value={destination ?? ''}
|
||||
onChange={(event) => setSelectedTerritory(event.target.value)}
|
||||
>
|
||||
{!destinations.length && (
|
||||
<option value="">No empty stronghold</option>
|
||||
)}
|
||||
{destinations.map((to) => (
|
||||
<option key={to} value={to}>
|
||||
{territory(to).name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<output className="m-0 text-sm leading-6">
|
||||
{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.'}
|
||||
</output>
|
||||
<Button
|
||||
className="h-auto min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={disabled || !token || !destination}
|
||||
onClick={() => {
|
||||
if (token && destination)
|
||||
act({
|
||||
type: 'decision',
|
||||
token: token.id,
|
||||
territory: destination,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{token?.status === 'placed'
|
||||
? 'Declare relocation'
|
||||
: 'Declare placement'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={disabled}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Pass this Mentat opportunity
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
|
||||
<h3 id={`${formId}-title`}>Enemy of My Enemy</h3>
|
||||
<p className="text-sm leading-6">
|
||||
{moritani.name} offers you an alliance after your arrival in{' '}
|
||||
{territory(entry.territory).name}. The Terror token remains hidden.
|
||||
</p>
|
||||
<p className="text-sm leading-6">
|
||||
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.
|
||||
</p>
|
||||
{[me, moritani]
|
||||
.filter((player) => player.ally)
|
||||
.map((player) => {
|
||||
const formerPartner = game.players.find(
|
||||
(partner) => partner.id === player.ally,
|
||||
);
|
||||
return (
|
||||
<p key={player.id} className="text-sm leading-6">
|
||||
{player.name}’s alliance with{' '}
|
||||
{formerPartner?.name ?? 'their current ally'} will end if you
|
||||
accept.
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<Button
|
||||
className={controlClass}
|
||||
disabled={disabled}
|
||||
onClick={() => act({ type: 'decision', accept: true })}
|
||||
>
|
||||
Accept alliance
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={controlClass}
|
||||
disabled={disabled}
|
||||
onClick={() => act({ type: 'decision', accept: false })}
|
||||
>
|
||||
Refuse alliance
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
|
||||
<h3 id={`${formId}-title`} className="break-words">
|
||||
{definition.name} · Terror reaction
|
||||
</h3>
|
||||
<p className="text-sm leading-6">
|
||||
{entrant?.name ?? 'Another faction'} entered{' '}
|
||||
{territory(entry.territory).name}, sector {entry.sector}, {arrival}.
|
||||
</p>
|
||||
{token && (
|
||||
<div className="mb-4 grid grid-cols-1">
|
||||
<TokenInspector token={token} owner />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{entry.stage === 'offer' && (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
You may reveal this token against the entrant, or leave it hidden
|
||||
and continue their action. Inspection keeps its face private.
|
||||
</p>
|
||||
{entry.kind === 'assassination' && entry.canReveal && (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Revealing Assassination immediately selects a random eligible
|
||||
native leader. You do not choose the victim.
|
||||
</p>
|
||||
)}
|
||||
{entry.kind === 'sneakAttack' && sneak && (
|
||||
<div className="space-y-2 text-sm leading-6">
|
||||
<p className="m-0">
|
||||
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.'}
|
||||
</p>
|
||||
<p className="m-0">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{entry.canOfferAlliance ? (
|
||||
<div className="flex flex-col gap-3 border-y border-current/20 py-3">
|
||||
<p className="m-0 text-sm leading-6">
|
||||
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.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={controlClass}
|
||||
disabled={disabled}
|
||||
onClick={() => act({ type: 'decision', alliance: true })}
|
||||
>
|
||||
Offer alliance
|
||||
</Button>
|
||||
</div>
|
||||
) : entry.allianceBlockedReason ? (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Enemy of My Enemy: {entry.allianceBlockedReason}
|
||||
</p>
|
||||
) : null}
|
||||
{entry.canReveal ? (
|
||||
<Button
|
||||
className={controlClass}
|
||||
disabled={disabled}
|
||||
onClick={() => act({ type: 'decision', reveal: true })}
|
||||
>
|
||||
Reveal {definition.name}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
{entry.revealBlocked ??
|
||||
`${definition.name} is still under implementation.`}{' '}
|
||||
Leave the token hidden to continue this arrival.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className={controlClass}
|
||||
disabled={disabled}
|
||||
onClick={() => act({ type: 'decision', decline: true })}
|
||||
>
|
||||
Leave token hidden
|
||||
</Button>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
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.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{entry.stage === 'robbery' && (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
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.
|
||||
</p>
|
||||
<Button
|
||||
className={controlClass}
|
||||
disabled={disabled}
|
||||
onClick={() => act({ type: 'decision', choice: 'spice' })}
|
||||
>
|
||||
Take half their spice
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={controlClass}
|
||||
disabled={disabled}
|
||||
onClick={() => act({ type: 'decision', choice: 'card' })}
|
||||
>
|
||||
Draw a Treachery Card
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{entry.stage === 'sneakAttack' && (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
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.
|
||||
</p>
|
||||
<label htmlFor={`${formId}-amount`} className="text-sm font-medium">
|
||||
Forces from your reserves
|
||||
</label>
|
||||
<select
|
||||
id={`${formId}-amount`}
|
||||
className="min-h-11 w-full min-w-0 max-w-full"
|
||||
disabled={disabled}
|
||||
value={amount}
|
||||
aria-describedby={`${formId}-entry-limit`}
|
||||
onChange={(event) =>
|
||||
setSelectedAmount(Number(event.target.value))
|
||||
}
|
||||
>
|
||||
{Array.from({ length: maximum + 1 }, (_, count) => (
|
||||
<option key={count} value={count}>
|
||||
{count}
|
||||
{count === 0 ? ' · no forces' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p id={`${formId}-entry-limit`} className="m-0 text-sm leading-6">
|
||||
{sneak?.blocked ??
|
||||
`Up to ${maximum} forces are available for this entry.`}
|
||||
</p>
|
||||
<Button
|
||||
className={controlClass}
|
||||
disabled={
|
||||
disabled || !sneak || amount <= 0 || Boolean(sneak.blocked)
|
||||
}
|
||||
onClick={() => {
|
||||
if (sneak && amount > 0 && !sneak.blocked)
|
||||
act({ type: 'decision', amount });
|
||||
}}
|
||||
>
|
||||
Send selected forces
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={controlClass}
|
||||
disabled={disabled}
|
||||
onClick={() => act({ type: 'decision', amount: 0 })}
|
||||
>
|
||||
Send no forces
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(entry.stage === 'discard' || giving) && (
|
||||
<>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
{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.'}
|
||||
</p>
|
||||
<label htmlFor={`${formId}-card`} className="text-sm font-medium">
|
||||
{giving ? 'Card to give (optional)' : 'Your card to discard'}
|
||||
</label>
|
||||
<select
|
||||
id={`${formId}-card`}
|
||||
className="min-h-11 w-full min-w-0 max-w-full"
|
||||
disabled={disabled || !hand.length}
|
||||
value={giving ? (selected?.id ?? '') : (discardCard?.id ?? '')}
|
||||
onChange={(event) => setSelectedCard(event.target.value)}
|
||||
>
|
||||
{giving && <option value="">None · keep my hand</option>}
|
||||
{!giving && !hand.length && (
|
||||
<option value="">No available card</option>
|
||||
)}
|
||||
{hand.map((card) => (
|
||||
<option key={card.id} value={card.id}>
|
||||
{card.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
className={controlClass}
|
||||
disabled={disabled || (!giving && !discardCard)}
|
||||
onClick={() => {
|
||||
if (giving && !selected)
|
||||
act({ type: 'decision', decline: true });
|
||||
else {
|
||||
const card = giving ? selected : discardCard;
|
||||
if (card) act({ type: 'decision', card: card.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{giving
|
||||
? selected
|
||||
? 'Give selected card'
|
||||
: 'Keep my hand'
|
||||
: 'Discard selected card'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<p className="notice">
|
||||
Your Nullentropy Box search is paid. Complete the private card choice in
|
||||
the decision panel above.
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<details className="my-4 min-w-0">
|
||||
<summary className="min-h-11 cursor-pointer py-3">
|
||||
Nullentropy Box: paid discard search
|
||||
</summary>
|
||||
<div className="flex min-w-0 flex-col gap-4 py-3">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Payment begins the search. There is no free cancellation after seeing
|
||||
the cards. If only one card is eligible, the server takes it
|
||||
automatically.
|
||||
</p>
|
||||
<CardRules card={box.card} />
|
||||
<CardInspector card={box.card} />
|
||||
{box.blocked && (
|
||||
<p id={`${id}-reason`} className="notice">
|
||||
{box.blocked}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || !!box.blocked}
|
||||
aria-describedby={box.blocked ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !box.blocked) act({ type: 'card', card: box.card.id });
|
||||
}}
|
||||
>
|
||||
Pay 2 spice and search privately
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p className="fine">
|
||||
These discard cards are visible only to you during this paid search.
|
||||
Inspection does not select a card.
|
||||
</p>
|
||||
<div
|
||||
className="grid min-w-0 gap-4"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 16rem), 1fr))' }}
|
||||
>
|
||||
{search.cards.map((card) => (
|
||||
<article
|
||||
key={card.id}
|
||||
className="flex min-w-0 flex-col gap-3 rounded-lg border border-[#65644b] p-4"
|
||||
>
|
||||
<h3>{card.name}</h3>
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!busy)
|
||||
act({ type: 'decision', event: search.event, card: card.id });
|
||||
}}
|
||||
>
|
||||
Take {card.name} and finish search
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<OrnithopterMode>('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<string, number> | 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<string, number> | undefined) ??
|
||||
(typeof move.from === 'string'
|
||||
? { [move.from]: Number(move.amount) }
|
||||
: {});
|
||||
const elites =
|
||||
(move.eliteForces as Record<string, number> | 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 (
|
||||
<section
|
||||
className="my-4 flex min-w-0 flex-col gap-4"
|
||||
aria-labelledby={`${id}-heading`}
|
||||
>
|
||||
<h3 id={`${id}-heading`}>
|
||||
{active
|
||||
? 'Ornithopter movement in progress'
|
||||
: 'Use Ornithopter for this movement'}
|
||||
</h3>
|
||||
<CardRules card={card.card} />
|
||||
<CardInspector card={card.card} />
|
||||
{active ? (
|
||||
<>
|
||||
<p>
|
||||
{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.'}
|
||||
</p>
|
||||
{!!active.cohort && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>Original unmoved forces available for selection:</p>
|
||||
<ul>
|
||||
{Object.entries(active.cohort.forces)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([key, n]) => {
|
||||
const source = splitLocation(key);
|
||||
return (
|
||||
<li key={key}>
|
||||
{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` : ''}.
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{active.cohort.noField && (
|
||||
<li>
|
||||
One original unmoved concealed No-Field; select its current
|
||||
source above.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label htmlFor={`${id}-mode`}>Card movement mode</label>
|
||||
<select
|
||||
id={`${id}-mode`}
|
||||
className="min-h-11 w-full min-w-0"
|
||||
value={mode}
|
||||
disabled={busy}
|
||||
onChange={(e) => setSelected(e.target.value as OrnithopterMode)}
|
||||
>
|
||||
{card.modes.map((option) => (
|
||||
<option
|
||||
key={option.mode}
|
||||
value={option.mode}
|
||||
disabled={!!option.blocked}
|
||||
>
|
||||
{option.mode === 'range3'
|
||||
? 'One group, up to three territories'
|
||||
: 'Two different groups, normal movement'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{card.modes
|
||||
.filter((option) => option.blocked)
|
||||
.map((option) => (
|
||||
<p className="fine" key={option.mode}>
|
||||
{option.mode === 'range3'
|
||||
? 'Three-territory mode'
|
||||
: 'Two-group mode'}
|
||||
: {option.blocked}
|
||||
</p>
|
||||
))}
|
||||
<p className="fine">
|
||||
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.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="notice" id={`${id}-reason`}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (busy || reason || !move || !mode) return;
|
||||
const action = { ...move };
|
||||
delete action.movementCard;
|
||||
delete action.ornithopter;
|
||||
delete action.ornithopterEvent;
|
||||
act({
|
||||
...action,
|
||||
...(active
|
||||
? { ornithopterEvent: active.event }
|
||||
: { movementCard: card.card.id, ornithopter: mode }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{active
|
||||
? 'Move next selected group'
|
||||
: 'Play Ornithopter and move selected group'}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<details className="my-4 min-w-0">
|
||||
<summary className="min-h-11 cursor-pointer py-3">
|
||||
Portable Snooper: poison defense
|
||||
</summary>
|
||||
<div className="flex min-w-0 flex-col gap-4 py-3">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<CardRules card={snooper.card} />
|
||||
<CardInspector card={snooper.card} />
|
||||
{reason && (
|
||||
<p className="notice" id={`${id}-reason`}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !reason && snooper.event)
|
||||
act({
|
||||
type: 'portableSnooper',
|
||||
card: snooper.card.id,
|
||||
event: snooper.event,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Play Portable Snooper as a late defense
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section
|
||||
className="private-battle-plan"
|
||||
aria-label="Private battle plan inspection"
|
||||
>
|
||||
<span className="eyebrow">Atreides · private inspection</span>
|
||||
<h3>
|
||||
{game.players.find((p) => p.id === insight.target)?.name}’s committed
|
||||
plan <HelpTip topic="fullPlan" />
|
||||
</h3>
|
||||
<p className="muted">
|
||||
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.
|
||||
</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Dial</dt>
|
||||
<dd>{insight.plan.dial}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Spice support</dt>
|
||||
<dd>{insight.plan.support}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Leader</dt>
|
||||
<dd>{name(insight.plan.leader)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Weapon</dt>
|
||||
<dd>{name(insight.plan.weapon)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Defense</dt>
|
||||
<dd>{name(insight.plan.defense)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Kwisatz Haderach</dt>
|
||||
<dd>{insight.plan.kwisatz ? 'Included' : 'Not included'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<BattleComponentInspection
|
||||
playerName={
|
||||
game.players.find((p) => p.id === insight.target)?.name ?? 'Player'
|
||||
}
|
||||
cards={insight.cards}
|
||||
leader={
|
||||
leader
|
||||
? {
|
||||
name: leader.name,
|
||||
factionName: faction(leader.faction).name,
|
||||
strength: leaderStrengthLabel(leader),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<details className="my-4 min-w-0">
|
||||
<summary className="min-h-11 cursor-pointer py-3">
|
||||
Residual Poison: before choosing leaders
|
||||
</summary>
|
||||
<div className="flex min-w-0 flex-col gap-4 py-3">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<CardRules card={poison.card} />
|
||||
<CardInspector card={poison.card} />
|
||||
{reason && (
|
||||
<p className="notice" id={`${id}-reason`}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !reason && poison.target && poison.event)
|
||||
act({
|
||||
type: 'card',
|
||||
card: poison.card.id,
|
||||
target: poison.target,
|
||||
event: poison.event,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Play Residual Poison against {target?.name ?? 'your battle opponent'}
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<section
|
||||
className="my-4 flex min-w-0 flex-col gap-3"
|
||||
aria-labelledby={`${id}-heading`}
|
||||
>
|
||||
<h3 id={`${id}-heading`}>Before choosing battle leaders</h3>
|
||||
<p>
|
||||
Both combatants have an opportunity to use available preparations before
|
||||
either commits a leader. Continue when you are ready to choose leaders.
|
||||
</p>
|
||||
<ul>
|
||||
{combatants.map((player) => (
|
||||
<li key={player}>
|
||||
{game.players.find((p) => p.id === player)?.name ?? 'Combatant'}:{' '}
|
||||
{stage.ready.includes(player)
|
||||
? 'Ready to choose leaders'
|
||||
: 'Preparing'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{mine &&
|
||||
(!ready ? (
|
||||
<>
|
||||
{interrupted && (
|
||||
<p className="notice" id={`${id}-reason`}>
|
||||
Resolve the current interaction before continuing.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || interrupted}
|
||||
aria-describedby={interrupted ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !interrupted)
|
||||
act({ type: 'battlePreparationReady', event: stage.event });
|
||||
}}
|
||||
>
|
||||
Ready to choose leaders
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="fine">
|
||||
You are ready. Waiting for the other combatant.
|
||||
</p>
|
||||
))}
|
||||
{!mine && (
|
||||
<p className="fine">
|
||||
Waiting for the combatants to finish preparation.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="revealed-plans" aria-label="Revealed battle plans">
|
||||
{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 (
|
||||
<div key={id}>
|
||||
<b>{game.players.find((p) => p.id === id)?.name}</b>
|
||||
<span>
|
||||
Dial {plan.dial}
|
||||
{game.advanced ? ` · ${plan.support} spice support` : ''}
|
||||
{!!plan.allyPayment && ` (${plan.allyPayment} from CHOAM)`}
|
||||
</span>
|
||||
<span>Leader: {name(plan.leader)}</span>
|
||||
{plan.kwisatz && (
|
||||
<span>
|
||||
Kwisatz Haderach · +2 if leader survives · protected from
|
||||
traitors
|
||||
</span>
|
||||
)}
|
||||
<span>Weapon: {name(plan.weapon)}</span>
|
||||
{undialed.length > 0 && (
|
||||
<span>
|
||||
Stone Burner undialed tokens: {undialed.join(' or ')}
|
||||
{undialed.length > 1
|
||||
? ' (possible counts before casualty choice)'
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
{battle.stoneBurner[id] && (
|
||||
<span>
|
||||
Stone Burner:{' '}
|
||||
{battle.stoneBurner[id] === 'kill'
|
||||
? 'both leaders die'
|
||||
: 'surviving leader strength ignored'}{' '}
|
||||
· compare undialed force tokens
|
||||
</span>
|
||||
)}
|
||||
{battle.poisonTooth[id] !== undefined && (
|
||||
<span>
|
||||
Poison Tooth: {battle.poisonTooth[id] ? 'activated' : 'unused'}
|
||||
</span>
|
||||
)}
|
||||
<span>Defense in original plan: {name(plan.defense)}</span>
|
||||
{battle.lateDefense[id] && (
|
||||
<span>Added after reveal: {name(battle.lateDefense[id])}</span>
|
||||
)}
|
||||
<BattleComponentInspection
|
||||
playerName={
|
||||
game.players.find((p) => 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
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, number>>({});
|
||||
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 (
|
||||
<div className="notice">
|
||||
<h3>Revival commerce</h3>
|
||||
<p>
|
||||
Allow another faction to revive five forces. Your ally pays for their
|
||||
own revivals; you may offer half price.
|
||||
</p>
|
||||
{g.advanced && (
|
||||
<div>
|
||||
<h4>Foreign gholas</h4>
|
||||
<p>
|
||||
Fill your active leader pool up to five. A ghola keeps its traitor
|
||||
identity and can be sold back only after it dies.
|
||||
</p>
|
||||
{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 (
|
||||
<Button
|
||||
key={l.id}
|
||||
variant="outline"
|
||||
disabled={
|
||||
busy ||
|
||||
cost > (me.spice ?? 0) ||
|
||||
me.gholaBlocked?.includes(l.id) ||
|
||||
me.leaders.filter(
|
||||
(leader) => !leader.dead && controlsLeader(me, leader),
|
||||
).length >= 5
|
||||
}
|
||||
onClick={() =>
|
||||
act({ type: 'reviveForeignGhola', leader: l.id })
|
||||
}
|
||||
>
|
||||
Revive {l.name} as a ghola · {cost} spice
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{g.players
|
||||
.filter((p) => p.id !== me.id)
|
||||
.map((p) => (
|
||||
<div key={p.id}>
|
||||
<span>
|
||||
{p.name} · {p.tanks} forces in the tanks{' '}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
busy ||
|
||||
g.revivalRules.limitBlocked ||
|
||||
g.revivalRules.expanded.includes(p.id)
|
||||
}
|
||||
onClick={() =>
|
||||
act({ type: 'tleilaxuRevivalLimit', target: p.id })
|
||||
}
|
||||
>
|
||||
Allow five revivals
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{me.ally && (
|
||||
<Button
|
||||
disabled={
|
||||
busy ||
|
||||
g.revivalRules.discountBlocked ||
|
||||
g.revivalRules.allyDiscount === me.ally
|
||||
}
|
||||
onClick={() => act({ type: 'tleilaxuAllyDiscount' })}
|
||||
>
|
||||
Offer ally half-price revival
|
||||
</Button>
|
||||
)}
|
||||
{Object.entries(g.revivalRequests).map(([id, request]) => (
|
||||
<div key={id}>
|
||||
<p>
|
||||
{g.players.find((p) => p.id === id)?.name} requests{' '}
|
||||
{name(request.leader)}.
|
||||
</p>
|
||||
<label htmlFor={`revival-price-${id}`}>
|
||||
Price in spice
|
||||
<Input
|
||||
id={`revival-price-${id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000000}
|
||||
value={prices[id] ?? 0}
|
||||
onChange={(e) =>
|
||||
setPrices({ ...prices, [id]: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'quoteLeaderRevival',
|
||||
target: id,
|
||||
amount: prices[id] ?? 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
Quote this price
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'declineLeaderRevival', target: id })}
|
||||
>
|
||||
Decline request
|
||||
</Button>
|
||||
<p className="fine">
|
||||
{request.declined
|
||||
? 'Declined'
|
||||
: request.price === null
|
||||
? 'No quote yet'
|
||||
: `Current quote: ${request.price} spice`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className="notice">
|
||||
<h3>Request a leader revival or ghola buyback</h3>
|
||||
<p>
|
||||
A request and its quoted price are visible only to you and Tleilaxu. No
|
||||
spice is paid until you accept and the response resolves.
|
||||
</p>
|
||||
{request ? (
|
||||
<>
|
||||
<p>
|
||||
{name(request.leader)} ·{' '}
|
||||
{request.declined
|
||||
? 'Request declined'
|
||||
: request.price === null
|
||||
? 'Awaiting a quote'
|
||||
: `${request.price} spice`}
|
||||
</p>
|
||||
{request.price !== null && !request.declined && (
|
||||
<Button
|
||||
disabled={busy || request.price > (me.spice ?? 0)}
|
||||
onClick={() => act({ type: 'acceptLeaderRevival' })}
|
||||
>
|
||||
Accept revival price
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'cancelLeaderRequest' })}
|
||||
>
|
||||
Withdraw request
|
||||
</Button>
|
||||
</>
|
||||
) : canRequest && (eligible.length > 0 || me.kwisatz?.dead) ? (
|
||||
<>
|
||||
<label htmlFor="revival-request-leader">
|
||||
Leader
|
||||
<select
|
||||
id="revival-request-leader"
|
||||
value={selection}
|
||||
onChange={(e) => setSelection(e.target.value)}
|
||||
>
|
||||
<option value="">Choose a dead leader</option>
|
||||
{eligible.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
{l.gholaBy ? ' · ghola buyback' : ''}
|
||||
</option>
|
||||
))}
|
||||
{me.kwisatz?.dead && (
|
||||
<option value="kwisatz">Kwisatz Haderach</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
disabled={busy || !selection}
|
||||
onClick={() =>
|
||||
act({ type: 'requestLeaderRevival', leader: selection })
|
||||
}
|
||||
>
|
||||
Request a quote
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="fine">
|
||||
Early revival needs an available native leader. A dead foreign ghola
|
||||
requires a buyback agreement; other leaders use ordinary revival when
|
||||
all are unavailable.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<p className="notice">
|
||||
This allied shipment offer is no longer available: {offer.blocked} Your
|
||||
ally may decline it without payment or token changes.
|
||||
</p>
|
||||
);
|
||||
if (offer)
|
||||
return (
|
||||
<p className="notice">
|
||||
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.
|
||||
</p>
|
||||
);
|
||||
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 (
|
||||
<details className="my-4" open>
|
||||
<summary className="min-h-11 cursor-pointer py-3 font-semibold">
|
||||
Offer {ally.name} a No-Field shipment
|
||||
</summary>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<p className="fine">
|
||||
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.
|
||||
</p>
|
||||
{info.private.deployed && (
|
||||
<p className="fine">
|
||||
If accepted and allowed, your existing concealed No-Field must
|
||||
reveal before your ally’s shipment.
|
||||
</p>
|
||||
)}
|
||||
<label htmlFor={`${id}-token`}>Private token</label>
|
||||
<select
|
||||
id={`${id}-token`}
|
||||
className="min-h-11"
|
||||
value={selected?.id ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
>
|
||||
{info.private.tokens.map((candidate) => (
|
||||
<option
|
||||
key={candidate.id}
|
||||
value={candidate.id}
|
||||
disabled={candidate.id === info.private!.lastShipped}
|
||||
>
|
||||
No-Field {candidate.value}
|
||||
{candidate.id === info.private!.lastShipped
|
||||
? ' · cannot repeat'
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p>Destination: {target.name}. Select another territory on the map.</p>
|
||||
<label htmlFor={`${id}-sector`}>Sector</label>
|
||||
<select
|
||||
id={`${id}-sector`}
|
||||
className="min-h-11"
|
||||
value={sector}
|
||||
disabled={busy}
|
||||
onChange={(event) => onSectorChange(Number(event.target.value))}
|
||||
>
|
||||
{target.sectors.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
{value === game.storm ? ' · storm' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label htmlFor={`${id}-payer`}>Pay the {cost} spice cost from</label>
|
||||
<select
|
||||
id={`${id}-payer`}
|
||||
className="min-h-11"
|
||||
value={selectedPayer}
|
||||
disabled={busy}
|
||||
onChange={(event) => setPayer(event.target.value)}
|
||||
>
|
||||
<option value={me.id}>{me.name} · you</option>
|
||||
<option value={ally.id}>{ally.name} · your ally</option>
|
||||
{cost === 2 && <option value="both">Each ally pays 1 spice</option>}
|
||||
</select>
|
||||
<p className="fine">
|
||||
{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.
|
||||
</p>
|
||||
{reasons.length > 0 && (
|
||||
<ul id={`${id}-reasons`} className="fine">
|
||||
{reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || reasons.length > 0}
|
||||
aria-describedby={reasons.length ? `${id}-reasons` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !reasons.length && selected)
|
||||
act({
|
||||
type: 'offerRicheseNoField',
|
||||
token: selected.id,
|
||||
event: info.event,
|
||||
territory: destination,
|
||||
sector,
|
||||
payer: selectedPayer,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Offer No-Field {selected?.value ?? ''} shipment
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{!offer.blocked && (
|
||||
<p>
|
||||
{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.
|
||||
</p>
|
||||
)}
|
||||
<p className="fine">
|
||||
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.
|
||||
</p>
|
||||
{offer.blocked && (
|
||||
<p className="notice" id={`${id}-blocked`}>
|
||||
{offer.blocked} You can decline without cost.
|
||||
</p>
|
||||
)}
|
||||
{!offer.blocked && offer.eliteMax > 0 && (
|
||||
<label htmlFor={`${id}-elite`}>
|
||||
Elite forces included ({offer.eliteMin}–{offer.eliteMax})
|
||||
<Input
|
||||
id={`${id}-elite`}
|
||||
type="number"
|
||||
min={offer.eliteMin}
|
||||
max={offer.eliteMax}
|
||||
value={selectedElite}
|
||||
disabled={busy}
|
||||
onChange={(event) => setElite(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !!offer.blocked}
|
||||
aria-describedby={offer.blocked ? `${id}-blocked` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !offer.blocked)
|
||||
act({
|
||||
type: 'decision',
|
||||
event: offer.event,
|
||||
accept: true,
|
||||
elite: selectedElite,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Accept allied No-Field shipment
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-11"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!busy)
|
||||
act({ type: 'decision', event: offer.event, decline: true });
|
||||
}}
|
||||
>
|
||||
Decline offer · no cost
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<RicheseAuctionMethod>('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<Action, 'type'>) => {
|
||||
if (!busy) act({ type: 'decision', event: bidding.event, ...fields });
|
||||
};
|
||||
if (decision.kind === 'richeseDeclaration')
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4"
|
||||
aria-label="Richese auction position"
|
||||
>
|
||||
<p>
|
||||
Choose whether your face-up cache card is auctioned before or after
|
||||
the ordinary cards. This declaration precedes Ixian auction
|
||||
inspection.
|
||||
</p>
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy}
|
||||
onClick={() => send({ position: 'first' })}
|
||||
>
|
||||
Auction cache card first
|
||||
</Button>
|
||||
<Button
|
||||
className={buttonClass}
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => send({ position: 'last' })}
|
||||
>
|
||||
Auction cache card last
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
if (decision.kind === 'richeseUnbid') {
|
||||
const room = (me.hand?.length ?? 0) < me.handLimit;
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4"
|
||||
aria-label="Unsold Richese cache card"
|
||||
>
|
||||
<p>
|
||||
No player offered spice. Keep the cache card for free if your hand has
|
||||
room, or remove it from this game.
|
||||
</p>
|
||||
{game.richeseAuction?.card && (
|
||||
<>
|
||||
<CardRules card={game.richeseAuction.card} />
|
||||
<CardInspector card={game.richeseAuction.card} />
|
||||
</>
|
||||
)}
|
||||
{!room && (
|
||||
<p className="fine">Your hand is full; the card cannot be added.</p>
|
||||
)}
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy || !room}
|
||||
onClick={() => send({ keep: true })}
|
||||
>
|
||||
Keep card for free
|
||||
</Button>
|
||||
<Button
|
||||
className={buttonClass}
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => send({ keep: false })}
|
||||
>
|
||||
Remove card from game
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<section
|
||||
className="flex min-w-0 flex-col gap-4"
|
||||
aria-label={blackMarket ? 'Black Market offer' : 'Richese cache offer'}
|
||||
>
|
||||
<p className="m-0 text-sm leading-6">
|
||||
{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.'}
|
||||
</p>
|
||||
{bidding.offerBlocked && (
|
||||
<p id={`${id}-offer-blocked`} className="notice">
|
||||
{bidding.offerBlocked}
|
||||
</p>
|
||||
)}
|
||||
{cards.length ? (
|
||||
<>
|
||||
<label htmlFor={`${id}-card`}>Card to auction</label>
|
||||
<select
|
||||
id={`${id}-card`}
|
||||
value={selected?.id ?? ''}
|
||||
disabled={busy}
|
||||
className="min-h-11 max-w-full"
|
||||
onChange={(event) => setCardId(event.target.value)}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<option key={card.id} value={card.id}>
|
||||
{card.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selected && (
|
||||
<>
|
||||
<CardRules card={selected} />
|
||||
<CardInspector card={selected} />
|
||||
</>
|
||||
)}
|
||||
<label htmlFor={`${id}-method`}>Auction method</label>
|
||||
<select
|
||||
id={`${id}-method`}
|
||||
value={chosenMethod}
|
||||
disabled={busy}
|
||||
className="min-h-11 max-w-full"
|
||||
onChange={(event) =>
|
||||
setMethod(event.target.value as RicheseAuctionMethod)
|
||||
}
|
||||
>
|
||||
{blackMarket && <option value="normal">Normal auction</option>}
|
||||
<option value="onceAround">Once Around</option>
|
||||
<option value="silent">Silent auction</option>
|
||||
</select>
|
||||
{chosenMethod === 'onceAround' && (
|
||||
<>
|
||||
<label htmlFor={`${id}-direction`}>Bidding direction</label>
|
||||
<select
|
||||
id={`${id}-direction`}
|
||||
value={direction}
|
||||
disabled={busy}
|
||||
className="min-h-11"
|
||||
onChange={(event) =>
|
||||
setDirection(event.target.value as typeof direction)
|
||||
}
|
||||
>
|
||||
<option value="clockwise">Clockwise</option>
|
||||
<option value="counterclockwise">Counterclockwise</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{blackMarket && (
|
||||
<>
|
||||
<label htmlFor={`${id}-claim`}>Public claim (optional)</label>
|
||||
<Input
|
||||
id={`${id}-claim`}
|
||||
value={claim}
|
||||
maxLength={300}
|
||||
disabled={busy}
|
||||
onChange={(event) => setClaim(event.target.value)}
|
||||
placeholder="What you say you are selling"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<p className="fine">
|
||||
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.'}
|
||||
</p>
|
||||
<Button
|
||||
className={buttonClass}
|
||||
aria-describedby={
|
||||
bidding.offerBlocked ? `${id}-offer-blocked` : undefined
|
||||
}
|
||||
disabled={busy || !selected || !!bidding.offerBlocked}
|
||||
onClick={() =>
|
||||
send({
|
||||
card: selected!.id,
|
||||
method: chosenMethod,
|
||||
direction,
|
||||
...(blackMarket && claim.trim() ? { claim } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
Offer {blackMarket ? 'Black Market' : 'cache'} card
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p>No card is available to offer.</p>
|
||||
)}
|
||||
{blackMarket && (
|
||||
<Button
|
||||
className={buttonClass}
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => send({ decline: true })}
|
||||
>
|
||||
Skip Black Market
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<typeof draft>) =>
|
||||
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 (
|
||||
<section
|
||||
className="flex min-w-0 flex-col gap-4"
|
||||
aria-label="Richese auction"
|
||||
>
|
||||
<h3 className="m-0 font-serif text-xl">
|
||||
{auction.source === 'cache' ? 'Richese cache' : 'Black Market'} ·{' '}
|
||||
{methodName[auction.method]}
|
||||
</h3>
|
||||
{auction.card ? (
|
||||
<>
|
||||
<p className="m-0 font-semibold">{auction.card.name}</p>
|
||||
{auction.source === 'blackMarket' && (
|
||||
<p className="fine">
|
||||
This face is private to you through ownership or authorized
|
||||
inspection.
|
||||
</p>
|
||||
)}
|
||||
<CardRules card={auction.card} />
|
||||
<CardInspector card={auction.card} />
|
||||
</>
|
||||
) : (
|
||||
<p>The Black Market card remains face down.</p>
|
||||
)}
|
||||
<p className="fine">
|
||||
{name(auction.owner)} is selling. Karama cannot acquire this card.
|
||||
</p>
|
||||
{auction.claim && (
|
||||
<p className="notice">Seller’s public claim: {auction.claim}</p>
|
||||
)}
|
||||
{silent ? (
|
||||
<>
|
||||
<p className="m-0">
|
||||
{auction.submitted.length} of {auction.eligible.length} bids
|
||||
submitted.
|
||||
</p>
|
||||
{auction.ownBid !== null && (
|
||||
<p className="notice">
|
||||
Your sealed bid: {auction.ownBid} spice, including{' '}
|
||||
{auction.ownAllyPayment} pledged by your ally.
|
||||
</p>
|
||||
)}
|
||||
{auction.revealedBids && (
|
||||
<ul>
|
||||
{Object.entries(auction.revealedBids).map(([player, bid]) => (
|
||||
<li key={player}>
|
||||
{name(player)}: {bid} spice
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="m-0">
|
||||
Highest bid: {auction.bid} spice
|
||||
{auction.bidder ? ` from ${name(auction.bidder)}` : ''}.
|
||||
</p>
|
||||
)}
|
||||
{allowedTurn ? (
|
||||
<>
|
||||
<p className="fine">
|
||||
Available funding: {auction.ownAvailable} own spice and{' '}
|
||||
{auction.allyAvailable} pledged spice.
|
||||
</p>
|
||||
{sellerBlocked && (
|
||||
<p className="notice">
|
||||
Positive Black Market bids by the seller are not supported.{' '}
|
||||
{silent ? 'Submit zero to continue.' : 'Pass to continue.'}
|
||||
</p>
|
||||
)}
|
||||
{!sellerBlocked && (
|
||||
<>
|
||||
<label htmlFor={`${id}-bid`}>
|
||||
{silent ? 'Sealed bid' : 'Your bid'} (minimum {minimum})
|
||||
</label>
|
||||
<Input
|
||||
id={`${id}-bid`}
|
||||
type="number"
|
||||
min={minimum}
|
||||
step={1}
|
||||
max={auction.ownAvailable + auction.allyAvailable}
|
||||
value={amount}
|
||||
disabled={busy}
|
||||
onChange={(event) => update({ amount: event.target.value })}
|
||||
/>
|
||||
{auction.allyAvailable > 0 && (
|
||||
<>
|
||||
<label htmlFor={`${id}-ally`}>Pay from ally’s pledge</label>
|
||||
<Input
|
||||
id={`${id}-ally`}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
max={Math.min(amount, auction.allyAvailable)}
|
||||
value={allyPayment}
|
||||
disabled={busy}
|
||||
onChange={(event) => update({ ally: event.target.value })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<p className="fine">
|
||||
{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.'}
|
||||
</p>
|
||||
<Button
|
||||
className={buttonClass}
|
||||
disabled={busy || !canBid}
|
||||
onClick={() => send(amount, allyPayment)}
|
||||
>
|
||||
{silent ? 'Seal bid' : 'Raise bid'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className={buttonClass}
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => send(silent ? 0 : null, 0)}
|
||||
>
|
||||
{silent ? 'Submit zero' : 'Pass this bid'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">
|
||||
{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.`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section
|
||||
className="my-4 flex min-w-0 flex-col gap-3"
|
||||
aria-labelledby={`${id}-pending`}
|
||||
>
|
||||
<h3 id={`${id}-pending`}>Richese gift awaiting resolution</h3>
|
||||
<p>
|
||||
{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.
|
||||
</p>
|
||||
{pending.card && (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<CardRules card={pending.card} />
|
||||
<CardInspector card={pending.card} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<details className="my-4 min-w-0">
|
||||
<summary className="min-h-11 cursor-pointer py-3">
|
||||
Give a Richese card to your ally
|
||||
</summary>
|
||||
<div className="flex min-w-0 flex-col gap-4 py-3">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{gift.cards.length > 0 && (
|
||||
<>
|
||||
<label htmlFor={`${id}-card`}>Card to give</label>
|
||||
<select
|
||||
id={`${id}-card`}
|
||||
className="min-h-11 w-full min-w-0"
|
||||
value={card?.id ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(event) => setSelection(event.target.value)}
|
||||
>
|
||||
{gift.cards.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{card && (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{reason && (
|
||||
<p id={`${id}-reason`} className="notice">
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal motion-reduce:transition-none"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !reason && card)
|
||||
act({ type: 'richeseGift', card: card.id });
|
||||
}}
|
||||
>
|
||||
Give {card?.name ?? 'a Richese card'} to{' '}
|
||||
{recipient?.name ?? 'your ally'}
|
||||
</Button>
|
||||
{gift.unavailable.length > 0 && (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-3"
|
||||
aria-labelledby={`${id}-unavailable`}
|
||||
>
|
||||
<h4 id={`${id}-unavailable`}>Cards unavailable to give</h4>
|
||||
<ul className="flex list-none flex-col gap-4 p-0">
|
||||
{gift.unavailable.map(
|
||||
({ card: unavailable, reason: unavailableReason }) => (
|
||||
<li
|
||||
key={unavailable.id}
|
||||
className="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<strong>{unavailable.name}</strong>
|
||||
<p>{unavailableReason}</p>
|
||||
<CardInspector card={unavailable} />
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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 [
|
||||
<g
|
||||
key={`no-field-${player.id}`}
|
||||
aria-label={label}
|
||||
transform={`translate(${t.center[0] - 45},${t.center[1] - 23})`}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<title>{label}</title>
|
||||
<rect
|
||||
x="-16"
|
||||
y="-15"
|
||||
width="32"
|
||||
height="30"
|
||||
rx="5"
|
||||
fill="#181921"
|
||||
stroke={faction(player.faction).color}
|
||||
strokeWidth="3"
|
||||
strokeDasharray="5 2"
|
||||
/>
|
||||
<text
|
||||
y="5"
|
||||
textAnchor="middle"
|
||||
fill="#f5ebd1"
|
||||
fontSize="12"
|
||||
fontWeight="800"
|
||||
>
|
||||
NF
|
||||
</text>
|
||||
</g>,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
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 (
|
||||
<details className="my-4" open={showShipment || !!deployed}>
|
||||
<summary className="min-h-11 cursor-pointer py-3 font-semibold">
|
||||
Your No-Field tokens
|
||||
</summary>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<p className="fine">
|
||||
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.
|
||||
</p>
|
||||
<ul
|
||||
className="m-0 flex flex-wrap gap-3 p-0"
|
||||
aria-label="Private No-Field inventory"
|
||||
>
|
||||
{state.tokens.map((token) => (
|
||||
<li
|
||||
key={token.id}
|
||||
className="list-none rounded border border-[#8e8159] px-3 py-2"
|
||||
>
|
||||
<strong>No-Field {token.value}</strong>
|
||||
<span className="block text-sm">
|
||||
{deployed?.tokenId === token.id
|
||||
? 'Concealed on the board'
|
||||
: state.lastShipped === token.id
|
||||
? 'Last used · cannot repeat'
|
||||
: 'Available'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{deployed && (
|
||||
<>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !info.canReveal}
|
||||
aria-describedby={
|
||||
!info.canReveal ? `${id}-reveal-reason` : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (!busy && info.canReveal)
|
||||
act({
|
||||
type: 'revealNoField',
|
||||
event: info.event,
|
||||
token: deployed.tokenId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reveal your No-Field
|
||||
</Button>
|
||||
{!info.canReveal && (
|
||||
<p id={`${id}-reveal-reason`} className="fine">
|
||||
{info.revealBlock ??
|
||||
'Voluntary revelation is not available now.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showShipment && (
|
||||
<>
|
||||
<label htmlFor={`${id}-token`}>Private token to ship</label>
|
||||
<select
|
||||
id={`${id}-token`}
|
||||
className="min-h-11"
|
||||
value={selected?.id ?? ''}
|
||||
disabled={busy || !!deployed}
|
||||
onChange={(event) => setSelection(event.target.value)}
|
||||
>
|
||||
{state.tokens.map((token) => (
|
||||
<option
|
||||
key={token.id}
|
||||
value={token.id}
|
||||
disabled={token.id === state.lastShipped}
|
||||
>
|
||||
No-Field {token.value}
|
||||
{token.id === state.lastShipped ? ' · cannot repeat' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="fine">
|
||||
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.
|
||||
</p>
|
||||
<ShipmentQuote
|
||||
id={`${id}-quote`}
|
||||
quote={
|
||||
validShare
|
||||
? {
|
||||
cost,
|
||||
normalCost: reserveShipmentCost(
|
||||
{ ...rate, halfRate: false },
|
||||
selectedTerritory.type,
|
||||
1,
|
||||
),
|
||||
ownPayment: cost - share,
|
||||
pledgedPayment: share,
|
||||
}
|
||||
: null
|
||||
}
|
||||
funding={{
|
||||
ownSpice: me.spice ?? 0,
|
||||
pledgedSpice: game.aid.available,
|
||||
}}
|
||||
unavailableReasons={problems}
|
||||
/>
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || problems.length > 0}
|
||||
aria-describedby={`${id}-quote`}
|
||||
onClick={() => {
|
||||
if (!busy && problems.length === 0 && selected)
|
||||
act({
|
||||
type: 'ship',
|
||||
noField: selected.id,
|
||||
event: info.event,
|
||||
territory: destination,
|
||||
sector,
|
||||
allyPayment: share,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Ship concealed No-Field {selected?.value ?? ''} · {cost} spice
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<details className="my-4">
|
||||
<summary className="min-h-11 cursor-pointer py-3">
|
||||
Richese special Karama: buy from your cache
|
||||
</summary>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<p>
|
||||
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}.
|
||||
</p>
|
||||
<p className="fine">
|
||||
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.
|
||||
</p>
|
||||
{purchase.karamas.length > 0 && (
|
||||
<>
|
||||
<label htmlFor={`${id}-activation`}>Karama to spend</label>
|
||||
<select
|
||||
id={`${id}-activation`}
|
||||
className="min-h-11"
|
||||
value={karama?.id ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(event) => setActivation(event.target.value)}
|
||||
>
|
||||
{purchase.karamas.map((card) => (
|
||||
<option value={card.id} key={card.id}>
|
||||
{card.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{karama && <CardInspector card={karama} />}
|
||||
</>
|
||||
)}
|
||||
{purchase.cards.length > 0 && (
|
||||
<>
|
||||
<label htmlFor={`${id}-selection`}>Private cache choice</label>
|
||||
<select
|
||||
id={`${id}-selection`}
|
||||
className="min-h-11"
|
||||
value={acquired?.id ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(event) => setSelection(event.target.value)}
|
||||
>
|
||||
{purchase.cards.map((card) => (
|
||||
<option value={card.id} key={card.id}>
|
||||
{card.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{acquired && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<CardRules card={acquired} />
|
||||
<CardInspector card={acquired} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{reason && (
|
||||
<p id={`${id}-reason`} className="notice">
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !!reason}
|
||||
aria-describedby={reason ? `${id}-reason` : undefined}
|
||||
onClick={() => {
|
||||
if (!busy && !reason && karama && acquired)
|
||||
act({
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: karama.id,
|
||||
acquire: acquired.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Spend Karama and 3 spice to buy {acquired?.name ?? 'a cache card'}
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense
|
||||
fallback={<p className="reference-page">Loading the rules reference…</p>}
|
||||
>
|
||||
<RulesReferenceContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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<Record<string, boolean>>({});
|
||||
const pendingScroll = useRef<string | null>(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 (
|
||||
<main className="reference-page">
|
||||
<header className="reference-header">
|
||||
<Link href="/">← Return to Arrakis</Link>
|
||||
<span className="eyebrow">
|
||||
<BookOpen size={14} /> THE MENTAT’S REFERENCE
|
||||
</span>
|
||||
<h1>
|
||||
Know the rules.
|
||||
<br />
|
||||
<em>Choose your moment.</em>
|
||||
</h1>
|
||||
<p>
|
||||
Find a phase, card or faction. Follow the action guides, inspect
|
||||
examples and move directly between related rules.
|
||||
</p>
|
||||
<p className="reference-status">
|
||||
This reference is growing with the game. Advanced play and expansion
|
||||
rules are unfinished.{' '}
|
||||
<Link href="/rules/coverage">
|
||||
View the implementation ledger <ArrowUpRight size={13} />
|
||||
</Link>
|
||||
{' · '}
|
||||
<Link href="/rules?topic=implementation-checklist#implementation-checklist">
|
||||
Open feature checklists <ArrowUpRight size={13} />
|
||||
</Link>
|
||||
</p>
|
||||
</header>
|
||||
<div className="reference-layout">
|
||||
<aside className="reference-nav">
|
||||
<label htmlFor="rules-search">
|
||||
<Search size={16} /> Search the reference
|
||||
</label>
|
||||
<Input
|
||||
id="rules-search"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Storm, Karama, allies…"
|
||||
/>
|
||||
<nav aria-label="Rule categories">
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
aria-pressed={category === c}
|
||||
onClick={() => setCategory(c)}
|
||||
>
|
||||
{c}
|
||||
<span>
|
||||
{c === 'All topics'
|
||||
? RULE_TOPICS.length
|
||||
: RULE_TOPICS.filter((t) => t.category === c).length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<section className="reference-results" aria-label="Rule topics">
|
||||
<output className="fine">
|
||||
{topics.length} {topics.length === 1 ? 'topic' : 'topics'}
|
||||
{query ? ` matching “${query}”` : ''}
|
||||
</output>
|
||||
{topics.length === 0 && (
|
||||
<p>No matching topics. Try a card name or choose All topics.</p>
|
||||
)}
|
||||
{topics.map((topic) => (
|
||||
<article id={topic.id} key={topic.id} className="rule-topic">
|
||||
<details
|
||||
open={terms.length > 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 },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<summary>
|
||||
<span className="eyebrow">{topic.category}</span>
|
||||
<h2>{topic.title}</h2>
|
||||
<span
|
||||
className={`coverage-label coverage-${topic.coverage.toLowerCase()}`}
|
||||
>
|
||||
{topic.coverage}
|
||||
</span>
|
||||
<p>{topic.summary}</p>
|
||||
</summary>
|
||||
<div className="rule-topic-content">
|
||||
<ol>
|
||||
{topic.steps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
{topic.id === 'stronghold-cards' && (
|
||||
<StrongholdCardGallery
|
||||
state={createStrongholdCards()}
|
||||
players={[]}
|
||||
/>
|
||||
)}
|
||||
{topic.id === 'richese-cards' && (
|
||||
<section
|
||||
aria-label="Richese card collection"
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
>
|
||||
{RICHESE_CARD_DEFINITIONS.map(({ card, printedType }) => (
|
||||
<article
|
||||
key={card.id}
|
||||
className="flex min-w-0 flex-col gap-3 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4"
|
||||
>
|
||||
<h3 className="m-0 break-words font-serif text-xl text-[#efd9a8]">
|
||||
{card.name}
|
||||
</h3>
|
||||
<p className="m-0 text-xs text-[#c1c5b8]">
|
||||
{printedType} · 1 card
|
||||
</p>
|
||||
<CardRules card={card} />
|
||||
<CardInspector card={card} />
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{topic.checklist && (
|
||||
<section
|
||||
aria-label={`${topic.title} implementation checklist`}
|
||||
>
|
||||
<h3>Feature checklist</h3>
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<table className="w-full text-left text-sm leading-6">
|
||||
<caption className="sr-only">
|
||||
{topic.title}: implementation, controls, AI,
|
||||
documentation and verification
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="p-2">
|
||||
Area
|
||||
</th>
|
||||
<th scope="col" className="p-2">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="p-2">
|
||||
Scope and evidence
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topic.checklist.map((item) => (
|
||||
<tr key={item.area}>
|
||||
<th scope="row" className="p-2 align-top">
|
||||
{item.area}
|
||||
</th>
|
||||
<td className="p-2 align-top">{item.status}</td>
|
||||
<td className="p-2 align-top">
|
||||
<p className="m-0">{item.detail}</p>
|
||||
{item.evidence && (
|
||||
<ul className="mt-2 list-disc pl-4">
|
||||
{item.evidence.map((file) => (
|
||||
<li key={file}>
|
||||
<code className="break-all text-xs">
|
||||
{file}
|
||||
</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{topic.id === 'battle' && (
|
||||
<aside className="rule-example">
|
||||
<strong>Try a basic battle wheel</strong>
|
||||
<p>
|
||||
Practice with ten ordinary forces and a strength-five
|
||||
leader. Use the slider, arrow keys or number field. In a
|
||||
game, your choice stays private until the plans are
|
||||
revealed.
|
||||
</p>
|
||||
<div style={{ maxWidth: 280 }}>
|
||||
<label htmlFor="practice-battle-dial">
|
||||
Practice dial
|
||||
</label>
|
||||
<BattleWheel
|
||||
id="practice-battle-dial"
|
||||
value={practiceDial}
|
||||
onChange={(value) =>
|
||||
setPracticeDial(
|
||||
Math.min(10, Math.max(0, Math.round(value))),
|
||||
)
|
||||
}
|
||||
max={10}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<p aria-live="polite">
|
||||
If the leader survives, your total is {practiceDial} + 5
|
||||
= {practiceDial + 5}. In a normal victory you lose the{' '}
|
||||
{practiceDial} dialed forces; in a defeat you lose all
|
||||
ten. Weapon, traitor and faction effects can change the
|
||||
result.
|
||||
</p>
|
||||
<p className="fine">
|
||||
This practice wheel does not change a live game.
|
||||
</p>
|
||||
</aside>
|
||||
)}
|
||||
{topic.example && (
|
||||
<aside className="rule-example">
|
||||
<strong>Example</strong>
|
||||
<p>{topic.example}</p>
|
||||
</aside>
|
||||
)}
|
||||
{topic.related && (
|
||||
<nav
|
||||
aria-label={`Related to ${topic.title}`}
|
||||
className="related-rules"
|
||||
>
|
||||
{topic.related.map((id) => (
|
||||
<a
|
||||
href={`/rules?topic=${id}#${id}`}
|
||||
key={id}
|
||||
onClick={() => {
|
||||
openTopic(id);
|
||||
}}
|
||||
>
|
||||
{RULE_TOPICS.find((t) => t.id === id)?.title ?? id}{' '}
|
||||
<ArrowUpRight size={13} />
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<a
|
||||
className="rule-permalink"
|
||||
href={`/rules?topic=${topic.id}#${topic.id}`}
|
||||
onClick={() => openTopic(topic.id)}
|
||||
>
|
||||
Link to this topic
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<Difficulty>('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 (
|
||||
<section
|
||||
className="notice flex min-w-0 flex-wrap items-center gap-4"
|
||||
aria-label="Your seat autopilot"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 basis-60 flex-col gap-2">
|
||||
<output className="flex items-center gap-2 text-base font-semibold text-[#efd9a8]">
|
||||
<Bot aria-hidden="true" className="size-5 shrink-0" />
|
||||
AI autopilot · {own.autopilot}
|
||||
</output>
|
||||
<p className="m-0 text-sm leading-6 text-[#e0e4d8]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 w-full motion-reduce:transition-none sm:w-auto"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!busy) act({ type: 'setAutopilot', difficulty: null });
|
||||
}}
|
||||
>
|
||||
<UserRound aria-hidden="true" />
|
||||
Take back control
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<details className="notice min-w-0">
|
||||
<summary className="min-h-11 cursor-pointer py-3 text-base font-semibold text-[#efd9a8] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ddbc77]">
|
||||
Let AI play for me
|
||||
</summary>
|
||||
<div className="flex min-w-0 flex-col gap-4 pb-2">
|
||||
<p id={`${id}-impact`} className="m-0 text-sm leading-6 text-[#e0e4d8]">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<label htmlFor={`${id}-difficulty`} className="text-sm font-semibold">
|
||||
AI difficulty
|
||||
</label>
|
||||
<select
|
||||
id={`${id}-difficulty`}
|
||||
value={difficulty}
|
||||
disabled={busy}
|
||||
aria-describedby={`${id}-difficulty-help`}
|
||||
className="min-h-11 w-full min-w-0 rounded-md border border-[#454b3c] bg-[#141814] px-3 text-base text-[#eeeae0] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ddbc77] disabled:opacity-50"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (DIFFICULTIES.some((choice) => choice === value))
|
||||
setDifficulty(value as Difficulty);
|
||||
}}
|
||||
>
|
||||
{DIFFICULTIES.map((choice) => (
|
||||
<option key={choice} value={choice}>
|
||||
{choice}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p
|
||||
id={`${id}-difficulty-help`}
|
||||
className="m-0 text-sm leading-6 text-[#c1c5b8]"
|
||||
>
|
||||
{BOT_DESCRIPTIONS[difficulty]}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="min-h-11 w-full motion-reduce:transition-none sm:w-auto sm:self-start"
|
||||
disabled={busy}
|
||||
aria-describedby={`${id}-impact`}
|
||||
onClick={() => {
|
||||
if (!busy) act({ type: 'setAutopilot', difficulty });
|
||||
}}
|
||||
>
|
||||
<Bot aria-hidden="true" />
|
||||
Start autopilot
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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<SeatRecoveryKit | null>(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<SetupResponse>(
|
||||
`/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 (
|
||||
<details className="notice">
|
||||
<summary className="cursor-pointer text-base font-semibold">
|
||||
Protect your saved seat
|
||||
</summary>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p className="fine">
|
||||
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.
|
||||
</p>
|
||||
{!kit ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => {
|
||||
try {
|
||||
setDraft(createRecoveryKit(game.code, game.me));
|
||||
setSaved(false);
|
||||
setConfirmed(false);
|
||||
setUncertain(false);
|
||||
setMessage('Save this kit before enabling its recovery key.');
|
||||
} catch (error) {
|
||||
setMessage((error as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyRound aria-hidden="true" />
|
||||
Generate private recovery kit
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<label htmlFor={`${id}-kit`}>
|
||||
Private recovery kit for room {kit.roomCode}
|
||||
</label>
|
||||
<Textarea
|
||||
id={`${id}-kit`}
|
||||
readOnly
|
||||
value={serializeRecoveryKit(kit)}
|
||||
rows={8}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="font-mono text-sm"
|
||||
aria-describedby={`${id}-privacy`}
|
||||
/>
|
||||
<p id={`${id}-privacy`} className="fine">
|
||||
This contains a secret key. Save the entire kit privately; do not
|
||||
put it in chat or the invitation.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void copyKit()}>
|
||||
<Copy aria-hidden="true" />
|
||||
Copy private kit
|
||||
</Button>
|
||||
<label className="flex items-start gap-2" htmlFor={`${id}-saved`}>
|
||||
<input
|
||||
id={`${id}-saved`}
|
||||
type="checkbox"
|
||||
checked={saved}
|
||||
disabled={disabled || busy}
|
||||
onChange={(event) => setSaved(event.target.checked)}
|
||||
/>
|
||||
I have saved the complete private kit outside this page.
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={disabled || busy || !saved || confirmed}
|
||||
onClick={() => void saveKey()}
|
||||
>
|
||||
{confirmed
|
||||
? 'Recovery key confirmed'
|
||||
: busy
|
||||
? 'Saving…'
|
||||
: uncertain
|
||||
? 'Retry saving this key'
|
||||
: 'Enable this recovery key'}
|
||||
</Button>
|
||||
{!uncertain && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => {
|
||||
setDraft(null);
|
||||
setSaved(false);
|
||||
setConfirmed(false);
|
||||
setMessage(
|
||||
confirmed
|
||||
? 'Saved kit hidden. Keep your private copy for recovery.'
|
||||
: 'Unsaved kit discarded.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
{confirmed ? 'Done — hide saved kit' : 'Discard unsaved kit'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<output className="block text-sm leading-relaxed" aria-live="polite">
|
||||
{message}
|
||||
</output>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeatRecoveryClaim({
|
||||
initialCode,
|
||||
onRestored,
|
||||
onPending,
|
||||
onUncertain,
|
||||
disabled = false,
|
||||
}: {
|
||||
initialCode?: string;
|
||||
onRestored: Restored;
|
||||
onPending?: (pending: boolean) => void;
|
||||
onUncertain?: (uncertain: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const pending = useRef(false);
|
||||
const [kitText, setKitText] = useState('');
|
||||
const [showKit, setShowKit] = useState(false);
|
||||
const [prepared, setPrepared] = useState<{
|
||||
room: string;
|
||||
attempt: SeatRecoveryAttempt;
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [uncertain, setUncertain] = useState(false);
|
||||
const [attempted, setAttempted] = useState(false);
|
||||
const [abandonAcknowledged, setAbandonAcknowledged] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
function inspectKit() {
|
||||
if (disabled || pending.current) return;
|
||||
try {
|
||||
const kit = parseRecoveryKit(kitText);
|
||||
setPrepared({ room: kit.roomCode, attempt: createRecoveryAttempt(kit) });
|
||||
setAttempted(false);
|
||||
setUncertain(false);
|
||||
setShowKit(false);
|
||||
setMessage('Kit read. Review the room and seat before recovering.');
|
||||
} catch (error) {
|
||||
setMessage((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function recover() {
|
||||
if (!prepared || disabled || pending.current) return;
|
||||
let confirmedView: GameView | undefined;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
onPending?.(true);
|
||||
setAttempted(true);
|
||||
setAbandonAcknowledged(false);
|
||||
setMessage('Recovering your seat…');
|
||||
try {
|
||||
const result = await requestJson<ClaimResponse>(
|
||||
`/api/rooms/${prepared.room}/control`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// The prepared UUID and session token are retained unchanged after a timeout.
|
||||
body: JSON.stringify(prepared.attempt),
|
||||
},
|
||||
);
|
||||
if (
|
||||
result.recovered !== true ||
|
||||
result.view.code !== prepared.room ||
|
||||
result.view.me !== prepared.attempt.playerId
|
||||
)
|
||||
throw new Error('The server did not confirm the recovered seat.');
|
||||
setKitText('');
|
||||
setPrepared(null);
|
||||
setShowKit(false);
|
||||
setAttempted(false);
|
||||
setUncertain(false);
|
||||
onUncertain?.(false);
|
||||
setMessage(
|
||||
'Seat recovered in this browser. Previous browser sessions for this seat have been revoked.',
|
||||
);
|
||||
confirmedView = result.view;
|
||||
} catch (error) {
|
||||
// A later rejection cannot prove that an earlier ambiguous attempt failed.
|
||||
// Retain that exact receipt until a response confirms this recovered seat.
|
||||
const mayHaveRecovered = uncertain || requestMayHaveCompleted(error);
|
||||
setUncertain(mayHaveRecovered);
|
||||
onUncertain?.(mayHaveRecovered);
|
||||
setMessage(
|
||||
mayHaveRecovered
|
||||
? 'Recovery was not confirmed and may have completed. Retry this same request below. Keep this page open to preserve its exact retry details; no retry is automatic.'
|
||||
: `${(error as Error).message} You can retry the same request or inspect a different saved kit.`,
|
||||
);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
onPending?.(false);
|
||||
}
|
||||
if (confirmedView) onRestored(confirmedView);
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="notice">
|
||||
<summary className="cursor-pointer text-base font-semibold">
|
||||
Recover a saved seat
|
||||
</summary>
|
||||
<p>
|
||||
Use the private kit saved before this browser lost its seat. Recovery
|
||||
restores that seat’s private information and revokes its previous
|
||||
browser sessions.
|
||||
</p>
|
||||
{initialCode && (
|
||||
<p className="fine">
|
||||
Invitation room: {initialCode}. The saved kit determines which room
|
||||
and seat you recover.
|
||||
</p>
|
||||
)}
|
||||
<label htmlFor={`${id}-claim`}>Paste your private recovery kit</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
id={`${id}-claim`}
|
||||
type={showKit ? 'text' : 'password'}
|
||||
value={kitText}
|
||||
readOnly={!!prepared}
|
||||
disabled={disabled || busy}
|
||||
onChange={(event) => setKitText(event.target.value)}
|
||||
maxLength={4096}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-describedby={`${id}-claim-help`}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-pressed={showKit}
|
||||
aria-controls={`${id}-claim`}
|
||||
onClick={() => setShowKit(!showKit)}
|
||||
>
|
||||
{showKit ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
|
||||
{showKit ? 'Hide kit' : 'Show kit'}
|
||||
</Button>
|
||||
</div>
|
||||
<p id={`${id}-claim-help`} className="fine">
|
||||
Keep this kit private. Reading it checks its format; Recover this seat
|
||||
submits it to restore your browser session.
|
||||
</p>
|
||||
{!prepared ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || !kitText.trim() || busy}
|
||||
onClick={inspectKit}
|
||||
>
|
||||
Read recovery kit
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<dl className="text-sm">
|
||||
<dt>Room to recover</dt>
|
||||
<dd className="font-mono">{prepared.room}</dd>
|
||||
<dt>Saved seat identifier</dt>
|
||||
<dd className="break-all font-mono">{prepared.attempt.playerId}</dd>
|
||||
</dl>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={disabled || busy} onClick={() => void recover()}>
|
||||
{busy
|
||||
? 'Recovering…'
|
||||
: attempted
|
||||
? 'Retry same recovery request'
|
||||
: 'Recover this seat'}
|
||||
</Button>
|
||||
{!uncertain && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => {
|
||||
setPrepared(null);
|
||||
setAttempted(false);
|
||||
setMessage('Paste or inspect your saved kit.');
|
||||
}}
|
||||
>
|
||||
Use a different kit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{uncertain && (
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
If this exact retry can no longer succeed, you can deliberately
|
||||
abandon its retry details. This does not cancel a completed
|
||||
recovery; previous browser sessions may already have been
|
||||
revoked. You need a saved recovery kit to recover again.
|
||||
</p>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={abandonAcknowledged}
|
||||
disabled={disabled || busy}
|
||||
onChange={(event) =>
|
||||
setAbandonAcknowledged(event.target.checked)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
I understand that abandoning drops the exact retry details and
|
||||
does not undo recovery.
|
||||
</span>
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || busy || !abandonAcknowledged}
|
||||
onClick={() => {
|
||||
if (disabled || pending.current || !abandonAcknowledged)
|
||||
return;
|
||||
setPrepared(null);
|
||||
setAttempted(false);
|
||||
setUncertain(false);
|
||||
setAbandonAcknowledged(false);
|
||||
setShowKit(false);
|
||||
onUncertain?.(false);
|
||||
setMessage(
|
||||
'Recovery retry details abandoned. The private kit remains in the input; read it again or paste a newer saved kit before starting another recovery.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
Abandon recovery attempt
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<output className="block text-sm leading-relaxed" aria-live="polite">
|
||||
{message}
|
||||
</output>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { liveShipmentPromises } from '@/game/shipment-promises';
|
||||
import { territory } from '@/game/board';
|
||||
import { reserveShipmentCost, guildShipmentCost } from '@/game/shipment-price';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function ShipmentPromises({
|
||||
game: g,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const promises = liveShipmentPromises(g.shipmentPromises, g.me, g.turn);
|
||||
if (!promises.length) return null;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const next = g.shipmentCompletion?.actions[0];
|
||||
const shipping = next && (next.type === 'ship' || next.type === 'guildShip');
|
||||
const cost = shipping
|
||||
? next.type === 'guildShip'
|
||||
? guildShipmentCost(
|
||||
territory(String(next.territory)).type,
|
||||
Number(next.amount),
|
||||
)
|
||||
: reserveShipmentCost(
|
||||
{
|
||||
faction: me.faction,
|
||||
halfRate:
|
||||
me.faction === 'guild' ||
|
||||
g.players.some(
|
||||
(p) => p.faction === 'guild' && p.id === me.ally,
|
||||
) ||
|
||||
g.karamaShipping?.player === me.id,
|
||||
},
|
||||
territory(String(next.territory)).type,
|
||||
Number(next.amount),
|
||||
)
|
||||
: null;
|
||||
const share = shipping
|
||||
? Number(next.allyPayment ?? Math.max(0, cost! - (me.spice ?? 0)))
|
||||
: 0;
|
||||
const description = next
|
||||
? shipping
|
||||
? `Ship ${Number(next.amount)} forces to ${territory(String(next.territory)).name}, sector ${Number(next.sector)}`
|
||||
: next.type === 'pledgeAid'
|
||||
? 'Reclaim your unused ally pledge'
|
||||
: `Play ${me.hand?.find((c) => c.id === next.card)?.name ?? 'your preparation card'}${next.amount ? ` to revive ${Number(next.amount)} forces` : ' for your shipment discount'}`
|
||||
: '';
|
||||
return (
|
||||
<section
|
||||
className="notice"
|
||||
aria-label="Your Truthtrance shipment promises"
|
||||
id="shipment-promise-guidance"
|
||||
>
|
||||
<h3>Your shipment promises</h3>
|
||||
<ul>
|
||||
{promises.map((p, index) => (
|
||||
<li key={index}>
|
||||
{p.answer ? 'Ship' : 'Do not ship'} at least {p.minimum} physical
|
||||
forces from reserves to {territory(p.territory).name} this turn.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{promises.some((p) => p.answer) && (
|
||||
<p>
|
||||
Complete a qualifying shipment before ground movement or finishing
|
||||
your turn. You can choose any legal count, sector and funding that
|
||||
honor your answers.
|
||||
</p>
|
||||
)}
|
||||
{next && (
|
||||
<details>
|
||||
<summary>Suggested next step · private</summary>
|
||||
<p>{description}.</p>
|
||||
{cost !== null && (
|
||||
<p>
|
||||
Cost: {cost} spice · Your payment: {cost - share} · Ally pledge:{' '}
|
||||
{share}.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={
|
||||
busy ||
|
||||
!!g.truthtrance ||
|
||||
!!g.response ||
|
||||
!!g.decision ||
|
||||
!!g.phaseOpening
|
||||
}
|
||||
onClick={() => act(next)}
|
||||
>
|
||||
{description}
|
||||
</Button>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export type ShipmentQuoteProps = {
|
||||
id?: string;
|
||||
label?: string;
|
||||
physicalForces?: number;
|
||||
quote: {
|
||||
cost: number;
|
||||
normalCost: number;
|
||||
ownPayment: number;
|
||||
pledgedPayment: number;
|
||||
} | null;
|
||||
funding: {
|
||||
ownSpice: number;
|
||||
pledgedSpice: number;
|
||||
};
|
||||
unavailableReasons?: readonly string[];
|
||||
};
|
||||
|
||||
/** Displays only caller-supplied prices and private funding already authorized for
|
||||
* this viewer. The caller validates the selection and controls action availability.
|
||||
*/
|
||||
export function ShipmentQuote({
|
||||
id,
|
||||
label = 'Reserve shipment',
|
||||
physicalForces,
|
||||
quote,
|
||||
funding,
|
||||
unavailableReasons = [],
|
||||
}: ShipmentQuoteProps) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className="notice shipment-quote flex min-w-0 flex-col gap-3 text-sm leading-6"
|
||||
aria-label={`${label} cost and funding`}
|
||||
>
|
||||
<div>
|
||||
{label !== 'Reserve shipment' && (
|
||||
<p className="m-0 font-semibold">{label}</p>
|
||||
)}
|
||||
<p className="m-0 text-base font-semibold text-[#efd9a8]">
|
||||
{quote ? `Shipment cost: ${quote.cost} spice` : 'Shipment cost'}
|
||||
</p>
|
||||
{quote ? (
|
||||
quote.normalCost !== quote.cost && (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Normal cost: {quote.normalCost} spice. The applicable rate is
|
||||
included in your shipment cost.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p className="m-0 text-sm leading-6">
|
||||
Choose valid forces and funding to see the payment breakdown.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<dl className="m-0 grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1">
|
||||
{physicalForces !== undefined && (
|
||||
<>
|
||||
<dt>Physical forces selected</dt>
|
||||
<dd className="m-0 text-right tabular-nums">{physicalForces}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>Your available spice</dt>
|
||||
<dd className="m-0 text-right tabular-nums">{funding.ownSpice}</dd>
|
||||
<dt>Available ally pledge</dt>
|
||||
<dd className="m-0 text-right tabular-nums">{funding.pledgedSpice}</dd>
|
||||
{quote && (
|
||||
<>
|
||||
<dt>Your payment</dt>
|
||||
<dd className="m-0 text-right tabular-nums">{quote.ownPayment}</dd>
|
||||
<dt>Ally pledge used</dt>
|
||||
<dd className="m-0 text-right tabular-nums">
|
||||
{quote.pledgedPayment}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{unavailableReasons.length > 0 && (
|
||||
<div>
|
||||
<p className="m-0 font-semibold">Shipment unavailable</p>
|
||||
<ul className="m-0 list-disc space-y-1 pl-5">
|
||||
{unavailableReasons.map((reason, index) => (
|
||||
<li key={index}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useId } from 'react';
|
||||
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
||||
import { Eye, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { SpiceCard } from '@/game/cards';
|
||||
import { territory } from '@/game/board';
|
||||
import { PHASE_HELP } from '@/game/reference';
|
||||
|
||||
/** Only the supplied visible face is read. No deck, discard pile or game is accepted. */
|
||||
export type SpiceCardInspectorProps = {
|
||||
card: Readonly<SpiceCard>;
|
||||
context?: string;
|
||||
};
|
||||
|
||||
export function spiceCardTitle(card: Readonly<SpiceCard>): string {
|
||||
if ('territory' in card)
|
||||
return `${territory(card.territory).name} · ${card.amount} spice · sector ${card.sector}`;
|
||||
if ('sandtrout' in card) return 'Sandtrout';
|
||||
return `Shai-Hulud${card.thumper ? ' · Thumper encounter' : ''}${card.suppressed ? ' · suppressed' : ''}`;
|
||||
}
|
||||
|
||||
/** Original vector illustration, with no claim to reproduce a printed card face. */
|
||||
function SpiceIllustration({
|
||||
kind,
|
||||
}: {
|
||||
kind: 'territory' | 'worm' | 'sandtrout';
|
||||
}) {
|
||||
const paint = `${useId()}-dune`;
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 260 190"
|
||||
className="my-5 w-full"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={paint} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0"
|
||||
stopColor={kind === 'sandtrout' ? '#344a42' : '#4f3d29'}
|
||||
/>
|
||||
<stop offset="1" stopColor="#171b17" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="258"
|
||||
height="188"
|
||||
rx="90"
|
||||
fill={`url(#${paint})`}
|
||||
stroke="#ac8c54"
|
||||
strokeOpacity="0.5"
|
||||
/>
|
||||
<circle cx="194" cy="47" r="16" fill="#dfbd7b" opacity="0.65" />
|
||||
<path
|
||||
d="M 12 121 Q 62 72 126 119 T 248 119 M 8 151 Q 76 95 144 147 T 252 143 M 30 174 Q 104 129 228 172"
|
||||
fill="none"
|
||||
stroke="#c5a76b"
|
||||
strokeWidth="2"
|
||||
strokeOpacity="0.65"
|
||||
/>
|
||||
{kind === 'territory' ? (
|
||||
<g fill="#dfb976" stroke="#fff0c4" strokeWidth="1.5">
|
||||
<path d="M 97 105 L 110 69 L 122 105 L 110 123 Z" />
|
||||
<path d="M 126 112 L 144 58 L 161 112 L 144 135 Z" />
|
||||
<path d="M 164 118 L 174 93 L 184 118 L 174 132 Z" />
|
||||
</g>
|
||||
) : kind === 'worm' ? (
|
||||
<g fill="none" stroke="#dec18a">
|
||||
<path
|
||||
d="M 59 136 C 66 88 67 59 106 45 C 143 31 170 73 165 127"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<ellipse
|
||||
cx="116"
|
||||
cy="77"
|
||||
rx="31"
|
||||
ry="37"
|
||||
fill="#141814"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<ellipse cx="116" cy="77" rx="20" ry="25" strokeWidth="1" />
|
||||
<path
|
||||
d="M 116 42 V 58 M 116 96 V 113 M 85 77 H 99 M 134 77 H 147 M 94 51 L 103 61 M 131 94 L 140 103 M 94 103 L 103 94 M 131 61 L 140 51"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</g>
|
||||
) : (
|
||||
<g fill="#7c9579" fillOpacity="0.3" stroke="#c6d1aa" strokeWidth="2">
|
||||
<path d="M 72 90 C 80 45 122 39 150 70 C 191 56 204 95 167 114 C 144 145 109 137 103 114 C 76 127 53 113 72 90 Z" />
|
||||
<path
|
||||
d="M 83 87 Q 120 63 159 87 M 96 107 Q 127 86 164 101"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpiceCardInspector({ card, context }: SpiceCardInspectorProps) {
|
||||
const kind =
|
||||
'territory' in card
|
||||
? 'territory'
|
||||
: 'sandtrout' in card
|
||||
? 'sandtrout'
|
||||
: 'worm';
|
||||
const title = spiceCardTitle(card);
|
||||
const faceName =
|
||||
'territory' in card
|
||||
? territory(card.territory).name
|
||||
: kind === 'worm'
|
||||
? 'Shai-Hulud'
|
||||
: 'Sandtrout';
|
||||
const description =
|
||||
kind === 'territory'
|
||||
? 'This spice blow names the territory, sector and base spice amount. The storm and card effects can change how much spice actually appears.'
|
||||
: kind === 'worm'
|
||||
? 'A Shai-Hulud encounter can consume forces and spice in the territory identified by the preceding card in its spice discard pile. Resolve protection, Nexus and riding decisions through the table.'
|
||||
: 'Sandtrout breaks existing alliances and waits for the next active drawn Shai-Hulud. It suppresses that encounter, then the immediate replacement determines whether a spice blow is doubled.';
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
aria-label={`Inspect spice card: ${title}${context ? ` · ${context}` : ''}`}
|
||||
>
|
||||
<Eye aria-hidden="true" />
|
||||
Inspect spice card
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
|
||||
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-reduce:animate-none motion-reduce:transition-none sm:max-h-[calc(100dvh-3rem)] sm:w-[calc(100%-3rem)]">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
|
||||
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
|
||||
Spice card inspection
|
||||
</span>
|
||||
<DialogClose
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
Close
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||||
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<article className="min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br from-[#493d29] via-[#2f3023] to-[#171b17] p-5 shadow-lg sm:p-6">
|
||||
<p className="m-0 text-sm font-semibold tracking-wider text-[#f1d79f] uppercase">
|
||||
{kind === 'territory'
|
||||
? 'Spice blow'
|
||||
: kind === 'worm'
|
||||
? 'Worm encounter'
|
||||
: 'Sandtrout'}
|
||||
</p>
|
||||
<SpiceIllustration kind={kind} />
|
||||
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
|
||||
{faceName}
|
||||
</DialogTitle>
|
||||
{'territory' in card && (
|
||||
<dl className="mt-5 grid grid-cols-2 gap-4 border-y border-[#b59a64]/40 py-4">
|
||||
<div>
|
||||
<dt className="text-sm text-[#d6c9ad]">Card amount</dt>
|
||||
<dd className="m-0 mt-1 font-serif text-4xl text-[#f6e4b9]">
|
||||
{card.amount}
|
||||
<span className="block font-sans text-sm">spice</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-[#d6c9ad]">Sector</dt>
|
||||
<dd className="m-0 mt-1 font-serif text-4xl text-[#f6e4b9]">
|
||||
{card.sector}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
{'worm' in card && card.thumper && (
|
||||
<p className="mt-4 text-base leading-7 text-[#eee5d2]">
|
||||
Thumper-created encounter
|
||||
</p>
|
||||
)}
|
||||
{'worm' in card && card.suppressed && (
|
||||
<p className="mt-4 text-base leading-7 text-[#eee5d2]">
|
||||
Suppressed by Sandtrout
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-5 mb-0 text-xs tracking-wider text-[#cebea0] uppercase">
|
||||
Spice · gameplay guide
|
||||
</p>
|
||||
</article>
|
||||
<section
|
||||
className="flex min-w-0 flex-col gap-5"
|
||||
aria-label="Spice card guidance"
|
||||
>
|
||||
{context && (
|
||||
<p className="m-0 text-sm font-semibold text-[#ddbc77]">
|
||||
{context}
|
||||
</p>
|
||||
)}
|
||||
<DialogDescription className="m-0 text-base leading-7 text-[#eee5d2]">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
Inspection does not draw a card or resolve an effect. Close
|
||||
this view to use the table’s phase controls.
|
||||
</p>
|
||||
{kind === 'territory' ? (
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
Spice cannot appear in the storm’s sector. A fresh blow
|
||||
remains open for Harvester before acceptance. The amount on
|
||||
this face is the card’s base value, not the total spice
|
||||
currently on the board.
|
||||
</p>
|
||||
) : kind === 'worm' ? (
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
First-turn drawn worms are ignored and later shuffled back.
|
||||
A worm suppressed by Sandtrout does not devour the preceding
|
||||
territory or create its own Nexus or Fremen ride. A normal
|
||||
encounter follows the table’s worm and alliance decisions.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
Territories drawn before the next active Shai-Hulud
|
||||
receive their usual spice. Sandtrout remains pending until
|
||||
that worm is suppressed, even across spice blows or turns.
|
||||
</p>
|
||||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||||
If the immediate replacement is a territory, double its
|
||||
spice; Harvester may enhance that fresh blow again. If the
|
||||
replacement is another Shai-Hulud, resolve it normally and
|
||||
do not double a later territory. Spice still cannot appear
|
||||
in the storm.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="m-0 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4 text-base leading-7 text-[#e0e4d8]">
|
||||
{PHASE_HELP[1]}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './ui/dialog';
|
||||
import type { Action } from '@/game/engine';
|
||||
import {
|
||||
STRONGHOLD_CARDS,
|
||||
type StrongholdId,
|
||||
type StrongholdState,
|
||||
} from '@/game/stronghold-cards';
|
||||
|
||||
type StrongholdDefinition = (typeof STRONGHOLD_CARDS)[number];
|
||||
|
||||
/** Original, bundled artwork; rules remain independent selectable text. */
|
||||
function StrongholdIllustration({ id }: { id: StrongholdId }) {
|
||||
return (
|
||||
<Image
|
||||
src={`/art/strongholds/${id}-v1.png`}
|
||||
alt=""
|
||||
unoptimized
|
||||
width={1774}
|
||||
height={887}
|
||||
loading="lazy"
|
||||
className="block aspect-[2/1] w-full object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StrongholdFace({
|
||||
card,
|
||||
ownership,
|
||||
children,
|
||||
enlarged = false,
|
||||
}: {
|
||||
enlarged?: boolean;
|
||||
card: StrongholdDefinition;
|
||||
ownership?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<article className="min-w-0 overflow-hidden rounded-xl border border-[#a88b60] bg-[#24271f] text-[#f2e8d3] shadow-lg">
|
||||
<div className="m-2 overflow-hidden rounded-lg border border-[#ab8a58]/60">
|
||||
<header className="flex min-h-20 items-center gap-3 bg-[#65362f] px-4 py-3">
|
||||
<h3 className="m-0 min-w-0 flex-1 break-words font-serif text-xl leading-tight text-[#fff1d4]">
|
||||
{card.name}
|
||||
</h3>
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
className="h-8 w-8 shrink-0"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<circle cx="16" cy="16" r="14" fill="none" stroke="#e2c18b" />
|
||||
<path d="M8 24V13h5v11m1 0V7h5v17m1 0v-9h4v9" fill="#e2c18b" />
|
||||
</svg>
|
||||
</header>
|
||||
<StrongholdIllustration id={card.id} />
|
||||
<div className="space-y-4 p-4">
|
||||
{ownership && (
|
||||
<p className="m-0 break-words border-b border-[#a88b60]/40 pb-3 text-sm font-semibold leading-6 text-[#edcc8c]">
|
||||
{ownership}
|
||||
</p>
|
||||
)}
|
||||
{card.gameplay.map((paragraph) => (
|
||||
<p
|
||||
key={paragraph}
|
||||
className={
|
||||
enlarged ? 'm-0 text-xl leading-8' : 'm-0 text-base leading-7'
|
||||
}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
{!enlarged && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 w-full whitespace-normal"
|
||||
/>
|
||||
}
|
||||
>
|
||||
Inspect {card.name}
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none" />
|
||||
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-[#a88b60] bg-[#171d18] text-[#f2e8d3] shadow-2xl outline-none motion-reduce:animate-none">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#a88b60]/40 px-4 py-3">
|
||||
<DialogTitle className="text-base leading-6">
|
||||
{card.name}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 min-w-16"
|
||||
/>
|
||||
}
|
||||
>
|
||||
Close
|
||||
</DialogClose>
|
||||
</div>
|
||||
<DialogDescription className="sr-only">
|
||||
Full Stronghold Card with its public ownership and gameplay
|
||||
text.
|
||||
</DialogDescription>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-3 sm:p-5">
|
||||
<StrongholdFace
|
||||
card={card}
|
||||
ownership={ownership}
|
||||
enlarged
|
||||
/>
|
||||
</div>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)}
|
||||
{children}
|
||||
<p className="m-0 border-t border-[#a88b60]/40 pt-3 text-xs leading-5 tracking-wide text-[#c9baa0]">
|
||||
CHOAM & Richese · Advanced Stronghold
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function StrongholdCardGallery({
|
||||
state,
|
||||
players,
|
||||
}: {
|
||||
state: StrongholdState;
|
||||
players: { id: string; name: string }[];
|
||||
}) {
|
||||
return (
|
||||
<section aria-label="Advanced Stronghold cards" className="space-y-4">
|
||||
<div>
|
||||
<h2 className="m-0 font-serif text-2xl text-[#f2e8d3]">
|
||||
Advanced Stronghold cards
|
||||
</h2>
|
||||
<p className="mt-2 mb-0 text-sm leading-6 text-[#c9baa0]">
|
||||
CHOAM & Richese · Optional module
|
||||
{state.claimedTurn > 0
|
||||
? ` · Last claim: turn ${state.claimedTurn}`
|
||||
: ' · Initial claims pending'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{STRONGHOLD_CARDS.map((card) => {
|
||||
const owner = state.owners[card.id];
|
||||
const name = players.find((player) => player.id === owner)?.name;
|
||||
return (
|
||||
<StrongholdFace
|
||||
key={card.id}
|
||||
card={card}
|
||||
ownership={owner ? `Held by ${name ?? owner}` : 'Unclaimed'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function StrongholdCopyChoice({
|
||||
event,
|
||||
choices,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
event: string;
|
||||
choices: StrongholdId[];
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const available = STRONGHOLD_CARDS.filter((card) =>
|
||||
choices.includes(card.id),
|
||||
);
|
||||
return (
|
||||
<section
|
||||
aria-label="Choose the mobile stronghold advantage"
|
||||
className="space-y-4"
|
||||
aria-busy={busy}
|
||||
>
|
||||
<p className="m-0 text-base leading-7">
|
||||
Choose the controlled stronghold advantage to copy before making battle
|
||||
plans.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
|
||||
{available.map((card) => (
|
||||
<StrongholdFace key={card.id} card={card}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="min-h-12 w-full whitespace-normal rounded-lg border border-[#eed29a] bg-[#d4b171] px-4 py-3 text-base font-semibold leading-6 text-[#1c211b] enabled:cursor-pointer enabled:hover:bg-[#edcc8c] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#edcc8c] disabled:cursor-wait disabled:opacity-50"
|
||||
onClick={() =>
|
||||
act({ type: 'decision', event, stronghold: card.id })
|
||||
}
|
||||
>
|
||||
Copy {card.name}
|
||||
</button>
|
||||
</StrongholdFace>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useId } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
|
||||
import type { CardCountFact } from '@/game/truthtrance-card-count';
|
||||
|
||||
export function CardCountFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: CardCountFact;
|
||||
onChange: (fact: CardCountFact) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const invalid = !Number.isSafeInteger(value.value) || value.value < 0;
|
||||
return (
|
||||
<>
|
||||
<label>
|
||||
Card name
|
||||
<select
|
||||
value={value.name}
|
||||
onChange={(event) => onChange({ ...value, name: event.target.value })}
|
||||
>
|
||||
{TRUTH_CARD_NAMES.map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Compare card count
|
||||
<select
|
||||
value={value.compare}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...value,
|
||||
compare: event.target.value as CardCountFact['compare'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="eq">Exactly</option>
|
||||
<option value="gte">At least</option>
|
||||
<option value="lte">At most</option>
|
||||
</select>
|
||||
</label>
|
||||
<label htmlFor={`${id}-count`}>
|
||||
Number of copies
|
||||
<Input
|
||||
id={`${id}-count`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={Number.MAX_SAFE_INTEGER}
|
||||
step={1}
|
||||
required
|
||||
value={Number.isNaN(value.value) ? '' : value.value}
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={`${id}-help${invalid ? ` ${id}-error` : ''}`}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, value: event.currentTarget.valueAsNumber })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p id={`${id}-help`} className="fine">
|
||||
Counts copies of this exact named card in the player’s hand now. Cards
|
||||
in the deck, discard pile, or a separate cache do not count. The answer
|
||||
reveals only whether the comparison is true.
|
||||
</p>
|
||||
{invalid && (
|
||||
<p id={`${id}-error`} className="notice" role="alert">
|
||||
{Number.isInteger(value.value) &&
|
||||
value.value > Number.MAX_SAFE_INTEGER
|
||||
? 'That card count is too large.'
|
||||
: 'Enter a whole card count of zero or more.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
'use client';
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import { BattleClaimFields } from './battle-promises';
|
||||
import { CardCountFields } from './truthtrance-card-count';
|
||||
import type { PlanClaim } from '@/game/battle-promises';
|
||||
import { useId, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import {
|
||||
TRUTH_CARD_NAMES,
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
type TruthQuestion,
|
||||
} from '@/game/truthtrance';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
|
||||
export function Truthtrance({
|
||||
game: g,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (a: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const w = g.truthtrance!;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const current = w.queue[0];
|
||||
const [target, setTarget] = useState(
|
||||
g.players.find((p) => p.id !== me.id)!.id,
|
||||
);
|
||||
const [kind, setKind] = useState<
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment'
|
||||
>('fact');
|
||||
const [shipmentTerritory, setShipmentTerritory] = useState('carthag');
|
||||
const [shipmentMinimum, setShipmentMinimum] = useState(6);
|
||||
const invalidShipment =
|
||||
!Number.isSafeInteger(shipmentMinimum) ||
|
||||
shipmentMinimum < 1 ||
|
||||
shipmentMinimum > 20;
|
||||
const shipmentAvailable =
|
||||
!g.advanced &&
|
||||
!g.expansions.length &&
|
||||
g.phase === 5 &&
|
||||
g.active === target &&
|
||||
!g.players.find((p) => p.id === target)?.shipped &&
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
!g.phaseOpening;
|
||||
const [planClauses, setPlanClauses] = useState<PlanClaim[]>([
|
||||
{ kind: 'dial', compare: 'gte', value: 1 },
|
||||
{ kind: 'weapon', name: null },
|
||||
]);
|
||||
const [scope, setScope] = useState<'fact' | 'currentTurn'>('fact');
|
||||
const [text, setText] = useState('');
|
||||
const [join, setJoin] = useState<'single' | 'and' | 'or'>('single');
|
||||
const [clauses, setClauses] = useState<TruthFact[]>([
|
||||
{ kind: 'hand', name: 'Shield' },
|
||||
{ kind: 'hand', name: 'Snooper' },
|
||||
]);
|
||||
const factId = useId();
|
||||
const invalidSpice = (fact: TruthFact) =>
|
||||
(fact.kind === 'spice' || fact.kind === 'handCount') &&
|
||||
(!Number.isSafeInteger(fact.value) || fact.value < 0);
|
||||
const invalidFact = clauses
|
||||
.slice(0, join === 'single' ? 1 : 2)
|
||||
.some(invalidSpice);
|
||||
const leaderName = (id: string) =>
|
||||
id === CHEAP_HERO_TRAITOR
|
||||
? 'Cheap Hero / Heroine'
|
||||
: (g.allLeaders.find((l) => l.id === id)?.name ?? id);
|
||||
const name = (id: string) => g.players.find((p) => p.id === id)!.name;
|
||||
const question: TruthQuestion =
|
||||
kind === 'fact'
|
||||
? {
|
||||
kind,
|
||||
target,
|
||||
fact: join === 'single' ? clauses[0] : { kind: join, terms: clauses },
|
||||
}
|
||||
: kind === 'battlePlan'
|
||||
? {
|
||||
kind,
|
||||
target,
|
||||
territory: g.battle?.territory ?? '',
|
||||
claim:
|
||||
join === 'single'
|
||||
? planClauses[0]
|
||||
: { kind: join, terms: planClauses },
|
||||
}
|
||||
: kind === 'shipment'
|
||||
? {
|
||||
kind,
|
||||
target,
|
||||
territory: shipmentTerritory,
|
||||
minimum: shipmentMinimum,
|
||||
}
|
||||
: { kind, target, text, scope };
|
||||
const button = (label: string, action: Action, disabled = false) => (
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={busy || disabled}
|
||||
onClick={() => act(action)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<section className="truthtrance-panel" aria-label="Truthtrance question">
|
||||
<h2>Truthtrance</h2>
|
||||
<p className="fine">
|
||||
<Link href="/rules#card-truthtrance">Question rules</Link> · The table
|
||||
resumes its previous decision after these questions.
|
||||
</p>
|
||||
{w.stage === 'priority' ? (
|
||||
<>
|
||||
<p>
|
||||
A Truthtrance has been declared. Declare yours now or pass;
|
||||
questions then follow storm order.
|
||||
</p>
|
||||
<ul>
|
||||
{g.players.map((p) => (
|
||||
<li key={p.id}>
|
||||
{p.name} ·{' '}
|
||||
{w.queue.some((q) => q.player === p.id)
|
||||
? 'Declared'
|
||||
: w.passed.includes(p.id)
|
||||
? 'Passed'
|
||||
: 'Choosing'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!w.passed.includes(me.id) && (
|
||||
<>
|
||||
{me.hand
|
||||
?.filter((c) => c.effect === 'truthtrance')
|
||||
.slice(0, 1)
|
||||
.map((c) => (
|
||||
<div key={c.id}>
|
||||
{button('Declare my Truthtrance', {
|
||||
type: 'card',
|
||||
card: c.id,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{(me.hand?.filter((c) => c.effect === 'truthtrance').length ??
|
||||
0) > 1 &&
|
||||
button('Declare both Truthtrances', {
|
||||
type: 'card',
|
||||
card: me.hand!.find((c) => c.effect === 'truthtrance')!.id,
|
||||
cards: me
|
||||
.hand!.filter((c) => c.effect === 'truthtrance')
|
||||
.slice(1)
|
||||
.map((c) => c.id),
|
||||
})}
|
||||
{button('Pass Truthtrance priority', { type: 'truthPass' })}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="eyebrow">
|
||||
{name(current.player)} holds the question · {w.queue.length} pending
|
||||
</p>
|
||||
{w.question && (
|
||||
<blockquote>
|
||||
<p>{truthQuestionText(w.question, leaderName)}</p>
|
||||
<p className="fine">Asked of {name(w.question.target)}</p>
|
||||
</blockquote>
|
||||
)}
|
||||
{w.stage === 'answer' ? (
|
||||
w.question!.target === me.id ? (
|
||||
<>
|
||||
<p>
|
||||
Your answer is public. Answer truthfully about the game; a
|
||||
promise can only bind decisions in this turn.
|
||||
</p>
|
||||
{w.question!.kind === 'shipment' ? (
|
||||
<>
|
||||
<p className="notice">
|
||||
A definite answer binds your shipment from reserves this
|
||||
turn. Available preparation includes your Ghola, Karama
|
||||
and recoverable allied funding. Yes leaves the exact
|
||||
count, sector and payment yours to choose; No allows a
|
||||
different shipment or none.
|
||||
</p>
|
||||
{(g.truthShipmentAnswers ?? []).map((answer) => (
|
||||
<div key={answer}>
|
||||
{button(
|
||||
answer === 'unknown'
|
||||
? 'I don’t know yet'
|
||||
: `Answer ${answer === 'yes' ? 'Yes' : 'No'}`,
|
||||
{ type: 'truthAnswer', answer },
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : w.question!.kind === 'battlePlan' ? (
|
||||
<>
|
||||
<p className="notice">
|
||||
Choose an answer you can honor in a legal plan, including
|
||||
available Ghola preparation. Earlier Truthtrance answers,
|
||||
Voice and revealed prescience elements apply. An answer
|
||||
about a sealed plan is already fixed.
|
||||
</p>
|
||||
{(g.truthBattleAnswers ?? []).map((answer) => (
|
||||
<div key={answer}>
|
||||
{button(
|
||||
answer === 'unknown'
|
||||
? 'I don’t know yet'
|
||||
: `Answer ${answer === 'yes' ? 'Yes' : 'No'}`,
|
||||
{ type: 'truthAnswer', answer },
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<p className="fine">
|
||||
A definite answer constrains your plan. Other plan
|
||||
elements remain yours to choose.
|
||||
</p>
|
||||
</>
|
||||
) : w.question!.kind === 'fact' ? (
|
||||
<>
|
||||
<p className="notice">
|
||||
Your current game information establishes:{' '}
|
||||
<strong>
|
||||
{g.truthAnswer === 'unknown'
|
||||
? 'I don’t know yet'
|
||||
: g.truthAnswer === 'yes'
|
||||
? 'Yes'
|
||||
: 'No'}
|
||||
</strong>
|
||||
. Only you see this until you answer.
|
||||
</p>
|
||||
{button('Publish my answer', {
|
||||
type: 'truthAnswer',
|
||||
answer: g.truthAnswer,
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{button('Answer Yes', {
|
||||
type: 'truthAnswer',
|
||||
answer: 'yes',
|
||||
})}
|
||||
{button('Answer No', { type: 'truthAnswer', answer: 'no' })}
|
||||
{button('I don’t know', {
|
||||
type: 'truthAnswer',
|
||||
answer: 'unknown',
|
||||
})}
|
||||
<p className="fine">
|
||||
Use “I don’t know” for something you cannot know or
|
||||
decide. The holder can ask a different question or save
|
||||
the card.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<output>Waiting for {name(w.question!.target)} to answer.</output>
|
||||
)
|
||||
) : current.player !== me.id ? (
|
||||
<output>
|
||||
Waiting for {name(current.player)} to{' '}
|
||||
{w.stage === 'unknown'
|
||||
? 'ask again or save the card'
|
||||
: 'ask a question'}
|
||||
.
|
||||
</output>
|
||||
) : (
|
||||
<>
|
||||
{w.stage === 'unknown' && (
|
||||
<>
|
||||
<p>
|
||||
The answer could not be known. Ask a different question or
|
||||
keep Truthtrance for later.
|
||||
</p>
|
||||
{button('Save card for later', { type: 'truthSave' })}
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
Ask which player?
|
||||
<select
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
>
|
||||
{g.players
|
||||
.filter((p) => p.id !== me.id)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.bot ? ' · AI' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Question type
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as typeof kind)}
|
||||
>
|
||||
<option value="fact">
|
||||
Verified cards, traitors or spice
|
||||
</option>
|
||||
<option value="shipment">
|
||||
Bind a shipment from reserves
|
||||
</option>
|
||||
<option value="freeform">Write a game question</option>
|
||||
{g.battle && (
|
||||
<option value="battlePlan">
|
||||
Bind a battle-plan choice
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{kind === 'shipment' ? (
|
||||
<>
|
||||
<label>
|
||||
Shipment destination
|
||||
<select
|
||||
value={shipmentTerritory}
|
||||
onChange={(e) => setShipmentTerritory(e.target.value)}
|
||||
>
|
||||
{TERRITORIES.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label htmlFor={`${factId}-shipment-minimum`}>
|
||||
Minimum physical forces
|
||||
<Input
|
||||
id={`${factId}-shipment-minimum`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
required
|
||||
value={
|
||||
Number.isNaN(shipmentMinimum) ? '' : shipmentMinimum
|
||||
}
|
||||
aria-invalid={invalidShipment}
|
||||
aria-describedby={`${factId}-shipment-help`}
|
||||
onChange={(e) =>
|
||||
setShipmentMinimum(e.currentTarget.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p className="fine" id={`${factId}-shipment-help`}>
|
||||
Includes Fremen reinforcements and Guild transport from
|
||||
southern reserves. Ground movement and transport of forces
|
||||
already on the board do not count.
|
||||
</p>
|
||||
{!shipmentAvailable && (
|
||||
<p className="notice">
|
||||
Automatic enforcement currently supports the active
|
||||
player’s unused shipment in the Basic game without
|
||||
expansions. Select that player and finish pending
|
||||
decisions first. Earlier questions and other rules
|
||||
configurations remain unfinished.
|
||||
</p>
|
||||
)}
|
||||
<p className="notice">
|
||||
{invalidShipment
|
||||
? 'Enter a whole minimum of one to twenty forces.'
|
||||
: truthQuestionText(question, leaderName)}
|
||||
</p>
|
||||
</>
|
||||
) : kind === 'battlePlan' ? (
|
||||
<>
|
||||
<label>
|
||||
Combine conditions
|
||||
<select
|
||||
value={join}
|
||||
onChange={(e) => setJoin(e.target.value as typeof join)}
|
||||
>
|
||||
<option value="single">One condition</option>
|
||||
<option value="and">Both · AND</option>
|
||||
<option value="or">Either · OR</option>
|
||||
</select>
|
||||
</label>
|
||||
{planClauses
|
||||
.slice(0, join === 'single' ? 1 : 2)
|
||||
.map((c, index) => (
|
||||
<fieldset key={index}>
|
||||
<legend>Condition {index + 1}</legend>
|
||||
<BattleClaimFields
|
||||
game={g}
|
||||
value={c}
|
||||
onChange={(next) =>
|
||||
setPlanClauses(
|
||||
planClauses.map((old, n) =>
|
||||
n === index ? next : old,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
))}
|
||||
<p className="notice">
|
||||
{truthQuestionText(question, leaderName)}
|
||||
</p>
|
||||
{g.battle &&
|
||||
![g.battle.attacker, g.battle.defender].includes(
|
||||
target,
|
||||
) && <p>Select one of the combatants for this question.</p>}
|
||||
</>
|
||||
) : kind === 'fact' ? (
|
||||
<>
|
||||
<label>
|
||||
Combine facts
|
||||
<select
|
||||
value={join}
|
||||
onChange={(e) => setJoin(e.target.value as typeof join)}
|
||||
>
|
||||
<option value="single">One fact</option>
|
||||
<option value="and">Both facts · AND</option>
|
||||
<option value="or">Either fact · OR</option>
|
||||
</select>
|
||||
</label>
|
||||
{clauses
|
||||
.slice(0, join === 'single' ? 1 : 2)
|
||||
.map((c, index) => (
|
||||
<fieldset key={index}>
|
||||
<legend>Fact {index + 1}</legend>
|
||||
<label>
|
||||
Fact type
|
||||
<select
|
||||
value={c.kind}
|
||||
onChange={(e) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index
|
||||
? e.target.value === 'hand'
|
||||
? { kind: 'hand', name: 'Shield' }
|
||||
: e.target.value === 'handCount'
|
||||
? {
|
||||
kind: 'handCount',
|
||||
name: 'Shield',
|
||||
compare: 'gte',
|
||||
value: 2,
|
||||
}
|
||||
: e.target.value === 'spice'
|
||||
? {
|
||||
kind: 'spice',
|
||||
compare: 'gte',
|
||||
value: 6,
|
||||
}
|
||||
: {
|
||||
kind: 'traitor',
|
||||
leader: g.allLeaders[0].id,
|
||||
}
|
||||
: old,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="hand">Holds a named card</option>
|
||||
<option value="handCount">
|
||||
Number of a named card
|
||||
</option>
|
||||
<option value="traitor">Selected a traitor</option>
|
||||
<option value="spice">
|
||||
Current personal spice
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{c.kind === 'hand' ? (
|
||||
<label>
|
||||
Card name
|
||||
<select
|
||||
value={c.name}
|
||||
onChange={(e) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index
|
||||
? { kind: 'hand', name: e.target.value }
|
||||
: old,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{TRUTH_CARD_NAMES.map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : c.kind === 'handCount' ? (
|
||||
<CardCountFields
|
||||
value={c}
|
||||
onChange={(next) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index ? next : old,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : c.kind === 'spice' ? (
|
||||
<>
|
||||
<label>
|
||||
Compare personal spice
|
||||
<select
|
||||
value={c.compare}
|
||||
onChange={(e) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index
|
||||
? {
|
||||
...c,
|
||||
compare: e.target
|
||||
.value as typeof c.compare,
|
||||
}
|
||||
: old,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="eq">Exactly</option>
|
||||
<option value="gte">At least</option>
|
||||
<option value="lte">At most</option>
|
||||
</select>
|
||||
</label>
|
||||
<label htmlFor={`${factId}-${index}-spice-amount`}>
|
||||
Spice amount
|
||||
<Input
|
||||
id={`${factId}-${index}-spice-amount`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={Number.MAX_SAFE_INTEGER}
|
||||
step={1}
|
||||
required
|
||||
value={Number.isNaN(c.value) ? '' : c.value}
|
||||
aria-invalid={invalidSpice(c)}
|
||||
aria-describedby={`${factId}-${index}-spice-help${invalidSpice(c) ? ` ${factId}-${index}-spice-error` : ''}`}
|
||||
onChange={(e) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index
|
||||
? {
|
||||
...c,
|
||||
value:
|
||||
e.currentTarget.valueAsNumber,
|
||||
}
|
||||
: old,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p
|
||||
className="fine"
|
||||
id={`${factId}-${index}-spice-help`}
|
||||
>
|
||||
Counts spice the player personally holds now.
|
||||
Excludes pledged aid and incoming payments. This
|
||||
asks about the current balance; it does not
|
||||
promise future holdings or spending.
|
||||
</p>
|
||||
{invalidSpice(c) && (
|
||||
<p
|
||||
className="notice"
|
||||
role="alert"
|
||||
id={`${factId}-${index}-spice-error`}
|
||||
>
|
||||
{Number.isInteger(c.value) &&
|
||||
c.value > Number.MAX_SAFE_INTEGER
|
||||
? 'That spice amount is too large.'
|
||||
: 'Enter a whole spice amount of zero or more.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
c.kind === 'traitor' && (
|
||||
<label>
|
||||
Leader
|
||||
<select
|
||||
value={c.leader}
|
||||
onChange={(e) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index
|
||||
? {
|
||||
kind: 'traitor',
|
||||
leader: e.target.value,
|
||||
}
|
||||
: old,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{g.allLeaders.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={CHEAP_HERO_TRAITOR}>
|
||||
Cheap Hero / Heroine
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
)}
|
||||
</fieldset>
|
||||
))}
|
||||
<p className="notice">
|
||||
{invalidFact
|
||||
? 'Enter a valid whole amount before asking this question.'
|
||||
: truthQuestionText(question, leaderName)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
Question concerns
|
||||
<select
|
||||
value={scope}
|
||||
onChange={(e) => setScope(e.target.value as typeof scope)}
|
||||
>
|
||||
<option value="fact">A game fact</option>
|
||||
<option value="currentTurn">
|
||||
A promise during turn {g.turn}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
One yes/no question
|
||||
<textarea
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Will you ship at least six forces to Carthag this turn?"
|
||||
/>
|
||||
</label>
|
||||
<p className="notice">
|
||||
Freeform answers rely on the players. Automatic checks for
|
||||
arbitrary promises are unfinished. AI can answer structured
|
||||
fact, supported shipment and battle-plan questions; it
|
||||
cannot yet interpret freeform questions.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{button(
|
||||
'Ask publicly',
|
||||
{ type: 'truthAsk', question },
|
||||
(kind === 'shipment' &&
|
||||
(invalidShipment || !shipmentAvailable)) ||
|
||||
(kind === 'freeform' && !text.trim()) ||
|
||||
(kind === 'fact' && invalidFact) ||
|
||||
(kind === 'battlePlan' &&
|
||||
(!g.battle ||
|
||||
![g.battle.attacker, g.battle.defender].includes(
|
||||
target,
|
||||
))),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function TruthHistory({ game: g }: { game: GameView }) {
|
||||
const records = g.truthHistory.filter((r) => r.turn === g.turn);
|
||||
if (!records.length) return null;
|
||||
const name = (id: string) => g.players.find((p) => p.id === id)?.name ?? id;
|
||||
return (
|
||||
<details className="notice truthtrance-history">
|
||||
<summary>Truthtrance answers · Turn {g.turn}</summary>
|
||||
<p className="fine">
|
||||
Structured battle-plan and supported reserve-shipment answers are
|
||||
enforced. Current-turn freeform promises remain binding when possible,
|
||||
but their automatic enforcement is unfinished.
|
||||
</p>
|
||||
<ol>
|
||||
{records.map((r, i) => (
|
||||
<li key={i}>
|
||||
<p>
|
||||
{name(r.asker)} → {name(r.question.target)}:{' '}
|
||||
{truthQuestionText(r.question, (id) =>
|
||||
id === CHEAP_HERO_TRAITOR
|
||||
? 'Cheap Hero / Heroine'
|
||||
: (g.allLeaders.find((l) => l.id === id)?.name ?? id),
|
||||
)}
|
||||
</p>
|
||||
<strong>
|
||||
{r.answer === 'unknown'
|
||||
? 'I don’t know'
|
||||
: r.answer === 'yes'
|
||||
? 'Yes'
|
||||
: 'No'}
|
||||
</strong>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
|
||||
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn('flex w-full flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn('not-last:border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
'focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none aria-disabled:pointer-events-none aria-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
|
||||
/>
|
||||
<ChevronUpIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Panel.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pt-0 pb-2.5 h-(--accordion-panel-height) data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = 'default',
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: 'default' | 'sm';
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
'bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2 cn-font-heading',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'default',
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
'text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn('absolute top-2 right-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction };
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function AspectRatio({
|
||||
ratio,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { ratio: number }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="aspect-ratio"
|
||||
style={
|
||||
{
|
||||
'--ratio': ratio,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn('relative aspect-(--ratio)', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { AspectRatio };
|
||||
@@ -0,0 +1,207 @@
|
||||
import * as React from 'react';
|
||||
import { mergeProps } from '@base-ui/react/merge-props';
|
||||
import { useRender } from '@base-ui/react/use-render';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const attachmentVariants = cva(
|
||||
'rounded-xl w-fit focus-within:ring-1 focus-within:ring-ring/50 group/attachment relative flex max-w-full min-w-0 shrink-0 flex-wrap border bg-card text-card-foreground transition-colors has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default:
|
||||
'gap-2 has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2 text-sm',
|
||||
sm: 'gap-2.5 has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5 text-xs',
|
||||
xs: 'gap-1.5 has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1 text-xs rounded-lg',
|
||||
},
|
||||
orientation: {
|
||||
horizontal: 'min-w-40 items-center',
|
||||
vertical: 'w-24 has-data-[slot=attachment-content]:w-30 flex-col',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Attachment({
|
||||
className,
|
||||
state = 'done',
|
||||
size = 'default',
|
||||
orientation = 'horizontal',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> &
|
||||
VariantProps<typeof attachmentVariants> & {
|
||||
state?: 'idle' | 'uploading' | 'processing' | 'error' | 'done';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment"
|
||||
data-state={state}
|
||||
data-size={size}
|
||||
data-orientation={orientation}
|
||||
className={cn(attachmentVariants({ size, orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const attachmentMediaVariants = cva(
|
||||
"bg-muted text-foreground w-10 rounded-lg group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md [&_svg:not([class*='size-'])]:size-4 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5 group-data-[orientation=vertical]/attachment:w-full group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! relative flex aspect-square shrink-0 items-center justify-center overflow-hidden group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
icon: '',
|
||||
image:
|
||||
'opacity-60 group-data-[state=idle]/attachment:opacity-100 group-data-[state=done]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'icon',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function AttachmentMedia({
|
||||
className,
|
||||
variant = 'icon',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof attachmentMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-media"
|
||||
data-variant={variant}
|
||||
className={cn(attachmentMediaVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-content"
|
||||
className={cn(
|
||||
'leading-tight group-data-[orientation=vertical]/attachment:px-1 max-w-full min-w-0 flex-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="attachment-title"
|
||||
className={cn(
|
||||
'font-medium block max-w-full min-w-0 truncate group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="attachment-description"
|
||||
className={cn(
|
||||
'mt-0.5 text-xs block min-w-0 truncate text-muted-foreground group-data-[state=error]/attachment:text-destructive/80',
|
||||
'max-w-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentActions({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-actions"
|
||||
className={cn(
|
||||
'group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 relative z-20 group-data-[orientation=vertical]/attachment:gap-1 flex shrink-0 items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentAction({
|
||||
className,
|
||||
variant,
|
||||
size = 'icon-xs',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="attachment-action"
|
||||
variant={variant ?? 'ghost'}
|
||||
size={size}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentTrigger({
|
||||
className,
|
||||
render,
|
||||
type,
|
||||
...props
|
||||
}: useRender.ComponentProps<'button'>) {
|
||||
return useRender({
|
||||
defaultTagName: 'button',
|
||||
props: mergeProps<'button'>(
|
||||
{
|
||||
type: render ? type : (type ?? 'button'),
|
||||
className: cn('absolute inset-0 z-10 outline-none', className),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: 'attachment-trigger',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function AttachmentGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-group"
|
||||
className={cn(
|
||||
'gap-3 scroll-px-1 py-1 flex min-w-0 scroll-fade-x snap-x snap-mandatory scrollbar-none overflow-x-auto overscroll-x-contain *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Attachment,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentContent,
|
||||
AttachmentTitle,
|
||||
AttachmentDescription,
|
||||
AttachmentActions,
|
||||
AttachmentAction,
|
||||
AttachmentTrigger,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = 'default',
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: 'default' | 'sm' | 'lg';
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
'rounded-full aspect-square size-full object-cover',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
|
||||
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
|
||||
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
|
||||
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from '@base-ui/react/merge-props';
|
||||
import { useRender } from '@base-ui/react/use-render';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||
destructive:
|
||||
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
|
||||
outline:
|
||||
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||
ghost:
|
||||
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = 'default',
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
props: mergeProps<'span'>(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: 'badge',
|
||||
variant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as React from 'react';
|
||||
import { mergeProps } from '@base-ui/react/merge-props';
|
||||
import { useRender } from '@base-ui/react/use-render';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
data-slot="breadcrumb"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
'text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn('gap-1 inline-flex items-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<'a'>) {
|
||||
return useRender({
|
||||
defaultTagName: 'a',
|
||||
props: mergeProps<'a'>(
|
||||
{
|
||||
className: cn('hover:text-foreground transition-colors', className),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: 'breadcrumb-link',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('text-foreground font-normal', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon className="cn-rtl-flip" />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-5 [&>svg]:size-4 flex items-center justify-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import * as React from 'react';
|
||||
import { mergeProps } from '@base-ui/react/merge-props';
|
||||
import { useRender } from '@base-ui/react/use-render';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="bubble-group"
|
||||
className={cn('gap-2 flex min-w-0 flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bubbleVariants = cva(
|
||||
'gap-1 data-[align=end]:self-end max-w-[80%] data-[variant=ghost]:max-w-full group-data-[align=end]/message:self-end group/bubble relative flex w-fit min-w-0 flex-col',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
|
||||
secondary:
|
||||
'*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
|
||||
muted:
|
||||
'*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
|
||||
tinted:
|
||||
'*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
|
||||
outline:
|
||||
'*:data-[slot=bubble-content]:bg-background *:data-[slot=bubble-content]:border-border [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
|
||||
ghost:
|
||||
'*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none',
|
||||
destructive:
|
||||
'*:data-[slot=bubble-content]:bg-destructive/10 dark:*:data-[slot=bubble-content]:bg-destructive/20 *:data-[slot=bubble-content]:text-destructive [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Bubble({
|
||||
variant = 'default',
|
||||
align = 'start',
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> &
|
||||
VariantProps<typeof bubbleVariants> & {
|
||||
align?: 'start' | 'end';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="bubble"
|
||||
data-variant={variant}
|
||||
data-align={align}
|
||||
className={cn(bubbleVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BubbleContent({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<'div'>) {
|
||||
return useRender({
|
||||
defaultTagName: 'div',
|
||||
props: mergeProps<'div'>(
|
||||
{
|
||||
className: cn(
|
||||
'rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50 group-data-[align=end]/bubble:self-end w-fit max-w-full min-w-0 overflow-hidden wrap-break-word [button]:text-left [button,a]:transition-colors',
|
||||
className,
|
||||
),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: 'bubble-content',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const bubbleReactionsVariants = cva(
|
||||
'rounded-full ring-3 ring-card bg-muted shrink-0 gap-1 px-1.5 py-0.5 has-[button]:p-0 text-sm absolute z-10 flex w-fit items-center justify-center',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'top-0 -translate-y-3/4',
|
||||
bottom: 'bottom-0 translate-y-3/4',
|
||||
},
|
||||
align: {
|
||||
start: 'left-3',
|
||||
end: 'right-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function BubbleReactions({
|
||||
side = 'bottom',
|
||||
align = 'end',
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
align?: 'start' | 'end';
|
||||
side?: 'top' | 'bottom';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="bubble-reactions"
|
||||
data-align={align}
|
||||
data-side={side}
|
||||
className={cn(bubbleReactionsVariants({ side, align }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions };
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mergeProps } from '@base-ui/react/merge-props';
|
||||
import { useRender } from '@base-ui/react/use-render';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! *:data-slot:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
|
||||
vertical:
|
||||
'[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! flex-col *:data-slot:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<'div'>) {
|
||||
return useRender({
|
||||
defaultTagName: 'div',
|
||||
props: mergeProps<'div'>(
|
||||
{
|
||||
className: cn(
|
||||
"bg-muted gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
||||
className,
|
||||
),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: 'button-group-text',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from '@base-ui/react/button';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
outline:
|
||||
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost:
|
||||
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
||||
destructive:
|
||||
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
icon: 'size-8',
|
||||
'icon-xs':
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm':
|
||||
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
type Locale,
|
||||
} from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronDownIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
locale,
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] group/calendar bg-background in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={locale}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString(locale?.code, { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn(
|
||||
'relative flex flex-col gap-4 md:flex-row',
|
||||
defaultClassNames.months,
|
||||
),
|
||||
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||
nav: cn(
|
||||
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'cn-calendar-dropdown-root relative rounded-(--cell-radius)',
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
'absolute inset-0 bg-popover opacity-0',
|
||||
defaultClassNames.dropdown,
|
||||
),
|
||||
caption_label: cn(
|
||||
'font-medium select-none',
|
||||
captionLayout === 'label'
|
||||
? 'cn-calendar-caption text-sm'
|
||||
: 'cn-calendar-caption-label flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
'w-(--cell-size) select-none',
|
||||
defaultClassNames.week_number_header,
|
||||
),
|
||||
week_number: cn(
|
||||
'text-[0.8rem] text-muted-foreground select-none',
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
|
||||
props.showWeekNumber
|
||||
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
|
||||
: '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn(
|
||||
'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
|
||||
defaultClassNames.range_end,
|
||||
),
|
||||
today: cn(
|
||||
'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
'text-muted-foreground opacity-50',
|
||||
defaultClassNames.disabled,
|
||||
),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return (
|
||||
<ChevronLeftIcon
|
||||
className={cn('cn-rtl-flip size-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === 'right') {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn('cn-rtl-flip size-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn('size-4', className)} {...props} />
|
||||
);
|
||||
},
|
||||
DayButton: ({ ...props }) => (
|
||||
<CalendarDayButton locale={locale} {...props} />
|
||||
),
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
locale,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
'font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn('px-(--card-spacing)', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
'flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from 'embla-carousel-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCarousel must be used within a <Carousel />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = 'horizontal',
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === 'horizontal' ? 'x' : 'y',
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return;
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return;
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return;
|
||||
onSelect(api);
|
||||
api.on('reInit', onSelect);
|
||||
api.on('select', onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off('select', onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
'min-w-0 shrink-0 grow-0 basis-full',
|
||||
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'icon-sm',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'rounded-full absolute touch-manipulation',
|
||||
orientation === 'horizontal'
|
||||
? 'inset-y-0 -left-12 my-auto'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon className="cn-rtl-flip" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'icon-sm',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'rounded-full absolute touch-manipulation',
|
||||
orientation === 'horizontal'
|
||||
? 'inset-y-0 -right-12 my-auto'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ChevronRightIcon className="cn-rtl-flip" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
useCarousel,
|
||||
};
|
||||
@@ -0,0 +1,373 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as RechartsPrimitive from 'recharts';
|
||||
import type { TooltipValueType } from 'recharts';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
|
||||
type TooltipNameType = number | string;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>;
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>['children'];
|
||||
initialDimension?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join('\n')}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join('\n'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<'div'> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: 'line' | 'dot' | 'dashed';
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
'accessibilityLayer'
|
||||
>) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/50 bg-background gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl grid min-w-32 items-start',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== 'none')
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
||||
indicator === 'dot' && 'items-center',
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === 'number'
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = 'bottom',
|
||||
nameKey,
|
||||
}: React.ComponentProps<'div'> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== 'none')
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload &&
|
||||
typeof payload.payload === 'object' &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === 'string'
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible';
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -0,0 +1,300 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Combobox as ComboboxPrimitive } from '@base-ui/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@/components/ui/input-group';
|
||||
import { ChevronDownIcon, XIcon, CheckIcon } from 'lucide-react';
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root;
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn('w-auto', className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = 'bottom',
|
||||
sideOffset = 6,
|
||||
align = 'start',
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor'
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 overflow-hidden rounded-lg shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-target cn-menu-translucent group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
'no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 p-1 data-empty:p-0 overflow-y-auto overscroll-contain',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
'text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
'dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-8 flex-wrap items-center gap-1 rounded-lg border bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:ring-3 has-aria-invalid:ring-3 has-data-[slot=combobox-chip]:px-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
'bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn('min-w-16 flex-1 outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { InputGroup, InputGroupAddon } from '@/components/ui/input-group';
|
||||
import { SearchIcon, CheckIcon } from 'lucide-react';
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = 'Command Palette',
|
||||
description = 'Search for a command to run...',
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, 'children'> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0',
|
||||
className,
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
'w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
'no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn('py-6 text-center text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
'text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! [&_svg:not([class*='size-'])]:size-4 group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react/context-menu';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon, CheckIcon } from 'lucide-react';
|
||||
|
||||
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
className,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn('select-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
align = 'start',
|
||||
alignOffset = 4,
|
||||
side = 'right',
|
||||
sideOffset = 0,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ContextMenuPrimitive.Positioner.Props,
|
||||
'align' | 'alignOffset' | 'side' | 'sideOffset'
|
||||
>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<ContextMenuPrimitive.Popup
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-target cn-menu-translucent z-50 max-h-(--available-height) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Positioner>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.GroupLabel
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: ContextMenuPrimitive.Item.Props & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubmenuTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="cn-rtl-flip ml-auto" />
|
||||
</ContextMenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuContent>) {
|
||||
return (
|
||||
<ContextMenuContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className="shadow-lg cn-menu-target cn-menu-translucent"
|
||||
side="right"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 pointer-events-none">
|
||||
<ContextMenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: ContextMenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 pointer-events-none">
|
||||
<ContextMenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
'font-heading text-base leading-none font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
'use client';
|
||||
|
||||
export {
|
||||
DirectionProvider,
|
||||
useDirection,
|
||||
} from '@base-ui/react/direction-provider';
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type DrawerContextProps = {
|
||||
hasSnapPoints: boolean;
|
||||
modal: DrawerPrimitive.Root.Props['modal'];
|
||||
showSwipeHandle: boolean;
|
||||
swipeDirection: NonNullable<DrawerPrimitive.Root.Props['swipeDirection']>;
|
||||
};
|
||||
|
||||
const DrawerContext = React.createContext<DrawerContextProps | null>(null);
|
||||
|
||||
function useDrawer() {
|
||||
const context = React.useContext(DrawerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useDrawer must be used within a Drawer.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function Drawer({
|
||||
modal = true,
|
||||
showSwipeHandle = false,
|
||||
snapPoints,
|
||||
swipeDirection = 'down',
|
||||
...props
|
||||
}: DrawerPrimitive.Root.Props & {
|
||||
showSwipeHandle?: boolean;
|
||||
}) {
|
||||
const hasSnapPoints = snapPoints != null && snapPoints.length > 0;
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
|
||||
[hasSnapPoints, modal, showSwipeHandle, swipeDirection],
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerContext.Provider value={contextValue}>
|
||||
<DrawerPrimitive.Root
|
||||
data-slot="drawer"
|
||||
modal={modal}
|
||||
snapPoints={snapPoints}
|
||||
swipeDirection={swipeDirection}
|
||||
{...props}
|
||||
/>
|
||||
</DrawerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DrawerPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DrawerPrimitive.Backdrop
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
'bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 min-h-dvh opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerSwipeHandle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-swipe-handle"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=up]/drawer-popup:items-start group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:order-last active:cursor-grabbing',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DrawerPrimitive.Popup.Props) {
|
||||
const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer();
|
||||
const swipeAxis =
|
||||
swipeDirection === 'down' || swipeDirection === 'up' ? 'y' : 'x';
|
||||
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
{modal === true && (
|
||||
<DrawerOverlay data-snap-points={hasSnapPoints ? '' : undefined} />
|
||||
)}
|
||||
<DrawerPrimitive.Viewport
|
||||
data-slot="drawer-viewport"
|
||||
data-modal={modal}
|
||||
className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
|
||||
>
|
||||
<DrawerPrimitive.Popup
|
||||
data-slot="drawer-popup"
|
||||
data-swipe-axis={swipeAxis}
|
||||
data-snap-points={hasSnapPoints ? '' : undefined}
|
||||
className={cn(
|
||||
// Base.
|
||||
'bg-popover text-popover-foreground text-sm data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords]',
|
||||
// Nested.
|
||||
'data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95',
|
||||
// Bleed.
|
||||
'after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full',
|
||||
// Sizing.
|
||||
'[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]',
|
||||
// Stack.
|
||||
'[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]',
|
||||
// Transitions.
|
||||
'data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]',
|
||||
// Axis: y.
|
||||
'data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)',
|
||||
// Axis: x.
|
||||
'data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row',
|
||||
// Direction: down.
|
||||
'data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]',
|
||||
// Direction: up.
|
||||
'data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]',
|
||||
// Direction: left.
|
||||
'data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]',
|
||||
// Direction: right.
|
||||
'data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showSwipeHandle && <DrawerSwipeHandle />}
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPrimitive.Popup>
|
||||
</DrawerPrimitive.Viewport>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
'gap-0.5 p-4 pb-0 md:gap-0.5 md:text-left flex shrink-0 flex-col group-data-[swipe-axis=y]/drawer-popup:text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn('gap-2 p-4 pt-0 mt-auto flex shrink-0 flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn(
|
||||
'text-foreground text-base font-medium cn-font-heading',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: DrawerPrimitive.Description.Props) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn('text-muted-foreground text-sm text-balance', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerSwipeHandle,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Menu as MenuPrimitive } from '@base-ui/react/menu';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon, CheckIcon } from 'lucide-react';
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = 'start',
|
||||
alignOffset = 0,
|
||||
side = 'bottom',
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
'align' | 'alignOffset' | 'side' | 'sideOffset'
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = 'start',
|
||||
alignOffset = -3,
|
||||
side = 'right',
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
className,
|
||||
)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
'gap-4 rounded-xl border-dashed p-6 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn('gap-2 flex max-w-sm flex-col items-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn(
|
||||
'text-sm font-medium tracking-tight cn-font-heading',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
'gap-2.5 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
'gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = 'legend',
|
||||
...props
|
||||
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
'gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
'data-[invalid=true]:text-destructive gap-2 group/field flex w-full',
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
|
||||
horizontal:
|
||||
'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
responsive:
|
||||
'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'vertical',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
'gap-0.5 group/field-content flex flex-1 flex-col leading-snug',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
'has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 *:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
'gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
'text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1',
|
||||
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
'-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="text-muted-foreground px-2 relative mx-auto block w-fit bg-background"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
errors?: Array<{ message?: string } | undefined>;
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
];
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>,
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}, [children, errors]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn('text-destructive text-sm font-normal', className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { PreviewCard as PreviewCardPrimitive } from '@base-ui/react/preview-card';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
|
||||
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
side = 'bottom',
|
||||
sideOffset = 4,
|
||||
align = 'center',
|
||||
alignOffset = 4,
|
||||
...props
|
||||
}: PreviewCardPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PreviewCardPrimitive.Positioner.Props,
|
||||
'align' | 'alignOffset' | 'side' | 'sideOffset'
|
||||
>) {
|
||||
return (
|
||||
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<PreviewCardPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PreviewCardPrimitive.Popup
|
||||
data-slot="hover-card-content"
|
||||
className={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 origin-(--transform-origin) outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PreviewCardPrimitive.Positioner>
|
||||
</PreviewCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
'border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
'inline-start':
|
||||
'pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first',
|
||||
'inline-end':
|
||||
'pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last',
|
||||
'block-start':
|
||||
'px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start',
|
||||
'block-end':
|
||||
'px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: 'inline-start',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = 'inline-start',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector('input')?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
'gap-2 text-sm flex items-center shadow-none',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: '',
|
||||
'icon-xs':
|
||||
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
|
||||
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'xs',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = 'button',
|
||||
variant = 'ghost',
|
||||
size = 'xs',
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MinusIcon } from 'lucide-react';
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
'cn-input-otp flex items-center has-disabled:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
spellCheck={false}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn(
|
||||
'has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive rounded-lg has-aria-invalid:ring-3 flex items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'dark:bg-input/30 border-input data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive size-8 border-y border-r text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg data-[active=true]:ring-3 relative flex items-center justify-center data-[active=true]:z-10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-separator"
|
||||
className="[&_svg:not([class*='size-'])]:size-4 flex items-center"
|
||||
role="separator"
|
||||
{...props}
|
||||
>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { Input as InputPrimitive } from '@base-ui/react/input';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
Loaded 100 of 771 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user