'use client'; import type { ReactElement, ReactNode } from 'react'; import type { GameView } from '@/game/engine'; import { mixedActionExpressionSize, parseMixedActionExpression, type MixedActionExpression } from '@/game/mixed-action-question'; 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'; export type MixedActionConfig = { prefix: string; legend: string; actionLabel: string; actionTiming: string; actionHelp: ReactNode; fallbackError: string; maxLeaves: number; maxDepth: number; maxNodes?: number; maxTerms?: number; requireFact?: boolean; newAction: () => A; parseAction: (value: unknown, game: GameView, target: string) => A; renderAction: (props: { value: A; id: string; game: GameView; target: string; disabled: boolean; onChange: (action: A) => void }) => ReactNode; }; const newFact = (): MixedActionExpression => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } }); const newFactLeaf = (): TruthFact => ({ kind: 'hand', name: 'Shield' }); function structuralError(node: MixedActionExpression, config: MixedActionConfig): string | null { const size = mixedActionExpressionSize(node); if (size.leaves > config.maxLeaves || size.depth > config.maxDepth) return 'Use at most sixteen conditions and four levels of grouping.'; if (config.maxNodes !== undefined && size.nodes > config.maxNodes) return `Use at most ${config.maxNodes} total clauses, including groups.`; if (config.maxTerms !== undefined && size.maxTerms > config.maxTerms) return `Use at most ${config.maxTerms} conditions in each AND/OR group.`; 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, config); if (error) return error; } return null; } /** Current-fact eligibility and each action family's parser share one envelope. */ export function mixedActionInputError(value: unknown, game: GameView, target: string, config: MixedActionConfig): 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; }, input => config.parseAction(input, game, target), { maxDepth: config.maxDepth, maxLeaves: config.maxLeaves, ...(config.maxNodes === undefined ? {} : { maxNodes: config.maxNodes }), ...(config.maxTerms === undefined ? {} : { maxTerms: config.maxTerms }), ...(config.requireFact === undefined ? {} : { requireFact: config.requireFact }) }); return null; } catch (error) { return error instanceof Error ? error.message : config.fallbackError; } } /** Preserve the complete original tree, including incomplete numeric drafts. * Structural changes never evaluate current facts or select a future branch. */ export function MixedActionFields({ value, game, target, disabled, onChange, config }: { value: MixedActionExpression; game: GameView; target: string; disabled: boolean; onChange: (expression: MixedActionExpression) => void; config: MixedActionConfig; }) { const newAction = (): MixedActionExpression => ({ kind: 'action', action: config.newAction() }); const attrs = (suffix: string, value: string) => ({ [`data-${config.prefix}-${suffix}`]: value }); const size = mixedActionExpressionSize(value), error = mixedActionInputError(value, game, target, config); const commit = (next: MixedActionExpression) => { if (!disabled && !structuralError(next, config)) onChange(next); }; const renderFact = (fact: TruthFact, path: string, replace: (next: TruthFact) => MixedActionExpression, remove?: () => MixedActionExpression): ReactElement => { const allowed = (next: TruthFact) => !disabled && !structuralError(replace(next), config); 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: MixedActionExpression
, path: string, replace: (next: MixedActionExpression) => MixedActionExpression, remove?: () => MixedActionExpression): ReactElement => { const id = `truth-${config.prefix}-${path}`; const allowed = (next: MixedActionExpression) => !disabled && !structuralError(replace(next), config); const change = (next: MixedActionExpression) => commit(replace(next)); const wrap = (kind: 'and' | 'or'): MixedActionExpression => ({ kind, terms: [node, node.kind === 'fact' ? newAction() : 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: MixedActionExpression = { ...node, terms: [...node.terms, { kind, terms: [newFact(), newAction()] }] }; return ; })}
: <> {node.kind === 'fact' ? renderFact(node.fact, path, fact => replace({ kind: 'fact', fact })) : node.kind === 'action' ? config.renderAction({ value: node.action, id, game, target, disabled, onChange: action => change({ ...node, action }) }) : null} }
{(['and', 'or'] as const).map(kind => )} {remove && }
; }; return
{config.legend}

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.

{config.actionHelp}

App input limits: {config.maxNodes === undefined ? `${config.maxLeaves} conditions` : `${config.maxNodes} total clauses, including groups`} {config.maxTerms !== undefined && `, ${config.maxTerms} conditions per group`} and {config.maxDepth} levels of grouping. These are input limits, not additional printed rules.

{config.maxNodes === undefined ? `${size.leaves} / ${config.maxLeaves} conditions` : `${size.nodes} / ${config.maxNodes} total clauses`} · {size.depth} / {config.maxDepth} levels of grouping

{error &&

{error}

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