'use client'; import type { ReactElement } from 'react'; import type { GameView } from '@/game/engine'; import { TERRITORIES } from '@/game/board'; import { parseMixedActionExpression, type MixedActionExpression } from '@/game/mixed-action-question'; import { MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES } from '@/game/movement-expression-promises'; import { parseMovementClaim, type MovementClaim } from '@/game/movement-promises'; import type { TruthFact } from '@/game/truthtrance'; import { truthFactExpressionError } from '@/game/truth-fact-expression'; import { TruthFactFields, invalidTruthFact } from './mixed-shipment-fields'; import { Button } from './ui/button'; import { Input } from './ui/input'; type MixedMovementExpression = MixedActionExpression; const newFact = (): MixedMovementExpression => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } }); const newMove = (): MixedMovementExpression => ({ kind: 'action', action: { territory: 'carthag', minimum: 1 } }); const newFactLeaf = (): TruthFact => ({ kind: 'hand', name: 'Shield' }); function factSize(fact: TruthFact): { leaves: number; depth: number } { if (fact.kind !== 'and' && fact.kind !== 'or') return { leaves: 1, depth: 0 }; const sizes = fact.terms.map(factSize); return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) }; } function sizeOf(node: MixedMovementExpression): { leaves: number; depth: number } { if (node.kind === 'fact') return factSize(node.fact); if (node.kind === 'action') return { leaves: 1, depth: 0 }; const sizes = node.terms.map(sizeOf); return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) }; } function structuralError(node: MixedMovementExpression): string | null { const size = sizeOf(node); if (size.leaves > MOVEMENT_EXPRESSION_MAX_LEAVES || size.depth > MOVEMENT_EXPRESSION_MAX_DEPTH) return 'Use at most sixteen conditions and four levels of grouping.'; if (node.kind === 'fact') return truthFactExpressionError(node.fact); if (node.kind === 'action') return null; if (node.terms.length < 2) return 'Join two or more conditions with AND or OR.'; for (const term of node.terms) { const error = structuralError(term); if (error) return error; } return null; } /** Draft feedback reuses the existing fact fields. The server remains the * authority for current-fact eligibility and legal future movement routes. */ export function mixedMovementInputError(value: unknown, game: GameView, target: string): string | null { try { parseMixedActionExpression(value, input => { const fact = input as TruthFact; const error = truthFactExpressionError(fact); if (error) throw new Error(error); if (invalidTruthFact(fact, game, target)) throw new Error('Resolve the marked current-fact fields.'); return fact; }, parseMovementClaim); return null; } catch (error) { return error instanceof Error ? error.message : 'Choose a current fact and a future ground-movement condition.'; } } /** Preserve the complete original tree, including incomplete numeric drafts. * Structural changes never evaluate current facts or select a future branch. */ export function MixedMovementFields({ value, game, target, disabled, onChange }: { value: MixedMovementExpression; game: GameView; target: string; disabled: boolean; onChange: (expression: MixedMovementExpression) => void; }) { const size = sizeOf(value), error = mixedMovementInputError(value, game, target); const commit = (next: MixedMovementExpression) => { if (!disabled && !structuralError(next)) onChange(next); }; const renderFact = (fact: TruthFact, path: string, replace: (next: TruthFact) => MixedMovementExpression, remove?: () => MixedMovementExpression): ReactElement => { const allowed = (next: TruthFact) => !disabled && !structuralError(replace(next)); const change = (next: TruthFact) => commit(replace(next)); const wrap = (kind: 'and' | 'or'): TruthFact => ({ kind, terms: [fact, newFactLeaf()] }); return
{fact.kind === 'and' || fact.kind === 'or' ? `Current-fact group ${path}` : `Current fact ${path}`} {fact.kind === 'and' || fact.kind === 'or' ? <> {fact.terms.map((term, index) => renderFact(term, `${path}.${index + 1}`, next => replace({ ...fact, terms: fact.terms.map((old, i) => i === index ? next : old) }), () => { const terms = fact.terms.filter((_, i) => i !== index); return replace(terms.length === 1 ? terms[0] : { ...fact, terms }); }))} : }
{(['and', 'or'] as const).map(kind => )} {remove && }
; }; const render = (node: MixedMovementExpression, path: string, replace: (next: MixedMovementExpression) => MixedMovementExpression, remove?: () => MixedMovementExpression): ReactElement => { const id = `truth-mixed-movement-${path}`; const allowed = (next: MixedMovementExpression) => !disabled && !structuralError(replace(next)); const change = (next: MixedMovementExpression) => commit(replace(next)); const wrap = (kind: 'and' | 'or'): MixedMovementExpression => ({ kind, terms: [node, node.kind === 'fact' ? newMove() : newFact()] }); return
{node.kind === 'and' || node.kind === 'or' ? `Mixed group ${path} · ${node.kind.toUpperCase()}` : `Mixed condition ${path}`} {node.kind === 'and' || node.kind === 'or' ? <> {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 }); }))}
{(['and', 'or'] as const).map(kind => { const next: MixedMovementExpression = { ...node, terms: [...node.terms, { kind, terms: [newFact(), newMove()] }] }; return ; })}
: <> {node.kind === 'fact' ? renderFact(node.fact, path, fact => replace({ kind: 'fact', fact })) : node.kind === 'action' ? <> 20} onChange={event => change({ ...node, action: { ...node.action, minimum: event.currentTarget.valueAsNumber } })} /> : null} }
{(['and', 'or'] as const).map(kind => )} {remove && }
; }; return
Mixed current-fact / future ground-movement claim

Current facts are evaluated when the player answers. They create no promise to keep cards, spice or forces afterward. AND requires every condition; OR requires at least one. No makes the whole grouped claim false, not necessarily every condition. Only the aggregate answer is shared; an unknown current fact can still allow a definite whole answer.

Each movement condition needs one actual future ground move after the answer, before your Movement turn ends this round. Smaller moves never add together. Prior moves and shipments do not count. The claim grants no extra movement opportunity.

App input limits: {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions and {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping. These are input limits, not additional printed rules.

{size.leaves} / {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping

{error &&

{error}

} {render(value, '1', next => next)}
; }