Files
Dune/components/movement-expression-fields.tsx

122 lines
8.1 KiB
TypeScript

'use client';
import type { ReactElement } from 'react';
import { TERRITORIES } from '@/game/board';
import {
parseMovementExpression, MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES,
type MovementExpression,
} from '@/game/movement-expression-promises';
import { Button } from './ui/button';
import { Input } from './ui/input';
type TerritoryChoice = { id: string; name: string };
export function movementExpressionInputError(value: unknown): string | null {
try { parseMovementExpression(value); return null; }
catch (error) { return error instanceof Error ? error.message : 'Choose a valid ground-movement expression.'; }
}
// Structural edits preserve repairable destination and numeric drafts. The
// detached valid placeholders are used only to check tree shape and limits.
const structuralDraft = (node: MovementExpression): MovementExpression => node.kind === 'move'
? { kind: 'move', territory: 'carthag', minimum: 1 }
: { ...node, terms: node.terms.map(structuralDraft) };
const inputSize = (node: MovementExpression): { leaves: number; depth: number } => {
if (node.kind === 'move') return { leaves: 1, depth: 0 };
const sizes = node.terms.map(inputSize);
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
};
/** Hook-free controlled tree. The server decides future route feasibility. */
export function MovementExpressionFields({ value, territories, disabled, onChange }: {
value: MovementExpression;
territories: readonly TerritoryChoice[];
disabled: boolean;
onChange: (expression: MovementExpression) => void;
}) {
const newLeaf = (): MovementExpression => ({ kind: 'move', territory: territories[0]?.id ?? 'carthag', minimum: 1 });
const size = inputSize(value), error = movementExpressionInputError(value);
const commit = (next: MovementExpression) => {
if (!disabled && !movementExpressionInputError(structuralDraft(next))) onChange(next);
};
const render = (node: MovementExpression, path: string, replace: (next: MovementExpression) => MovementExpression,
remove?: () => MovementExpression): ReactElement => {
const id = `truth-movement-expression-${path}`;
const allowed = (next: MovementExpression) => !disabled && !movementExpressionInputError(structuralDraft(replace(next)));
const change = (next: MovementExpression) => commit(replace(next));
const wrap = (kind: 'and' | 'or'): MovementExpression => ({ kind, terms: [node, newLeaf()] });
return <fieldset key={path} data-movement-expression-path={path} className="grid min-w-0 gap-2 p-2">
<legend>{node.kind === 'move' ? `Movement condition ${path}` : `Movement group ${path} · ${node.kind.toUpperCase()}`}</legend>
{node.kind === 'move' ? <>
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
<select id={`${id}-territory`} value={territories.some(destination => destination.id === node.territory) ? node.territory : ''}
disabled={disabled} aria-invalid={!territories.some(destination => destination.id === node.territory)}
onChange={event => change({ kind: 'move', territory: event.currentTarget.value, minimum: node.minimum })}>
<option value="">Choose a printed Arrakis territory</option>
{territories.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
</select>
<label htmlFor={`${id}-sector`}>Ground-movement sector</label>
<select id={`${id}-sector`} disabled={disabled} value={node.sector === undefined ? '' : String(node.sector)}
aria-invalid={node.sector !== undefined && !TERRITORIES.find(row => row.id === node.territory)?.sectors.includes(node.sector)}
onChange={event => {
const selected = event.currentTarget.value, printed = TERRITORIES.find(row => row.id === node.territory);
if (selected === '') change({ kind: 'move', territory: node.territory, minimum: node.minimum });
else { const sector = Number(selected); if (printed?.sectors.includes(sector)) change({ ...node, sector }); }
}}>
<option value="">Any sector in this territory</option>
{TERRITORIES.find(row => row.id === node.territory)?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</option>)}
</select>
<label htmlFor={`${id}-minimum`}>Minimum physical forces in one ground move</label>
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required disabled={disabled}
value={Number.isNaN(node.minimum) ? '' : node.minimum} aria-invalid={!!movementExpressionInputError(node)}
onChange={event => change({ ...node, minimum: event.currentTarget.valueAsNumber })} />
</> : <>
<label htmlFor={`${id}-meaning`}>Movement group {path} meaning</label>
<select id={`${id}-meaning`} value={node.kind} disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === 'and' || kind === 'or') change({ ...node, kind });
}}>
<option value="and">All conditions · AND</option>
<option value="or">At least one condition · OR</option>
</select>
{node.terms.map((term, index) => render(term, `${path}.${index + 1}`,
next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }),
() => {
const terms = node.terms.filter((_, i) => i !== index);
return replace(terms.length === 1 ? terms[0] : { ...node, terms });
}))}
<div className="flex flex-wrap gap-2">
<Button type="button" data-movement-expression-control={`add-${path}`}
disabled={!allowed({ ...node, terms: [...node.terms, newLeaf()] })}
onClick={() => change({ ...node, terms: [...node.terms, newLeaf()] })}>Add movement condition</Button>
{(['and', 'or'] as const).map(kind => {
const next: MovementExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] };
return <Button key={kind} type="button" data-movement-expression-control={`nested-${kind}-${path}`}
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
})}
</div>
</>}
<div className="flex flex-wrap gap-2">
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-movement-expression-control={`${kind}-${path}`}
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
{remove && <Button type="button" data-movement-expression-control={`remove-${path}`} disabled={disabled}
onClick={() => commit(remove())}>Remove {node.kind === 'move' ? 'condition' : 'group'}</Button>}
</div>
</fieldset>;
};
return <fieldset disabled={disabled} data-testid="movement-expression"
className="grid min-w-0 gap-2 [&_select]:min-w-0 [&_select]:max-w-full [&_button]:max-w-full [&_button]:min-h-11 [&_button]:h-auto [&_button]:py-2 [&_button]:whitespace-normal">
<legend>Ground-movement expression</legend>
<p className="fine">
AND requires every condition; OR requires at least one. No makes the whole expression false,
not necessarily every condition. Legal alternatives remain available; the question does not pin a fixed branch.
Each condition needs one future actual ground move after the answer, before your Movement turn ends this round.
Name an exact sector when needed. Smaller moves never add together; a shipment can prepare a move but does not fulfill a condition.
</p>
<p className="fine">App input limits: {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions and {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping.
Groups do not count as conditions. These are input limits, not additional printed rules.</p>
<p className="fine">{size.leaves} / {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping</p>
{error && <p role="alert">{error}</p>}
{render(value, '1', next => next)}
</fieldset>;
}