'use client'; import { useId, type ReactElement } from 'react'; import type { GameView } from '@/game/engine'; import type { MixedShipmentExpression } from '@/game/mixed-shipment-question'; import type { TruthFact } from '@/game/truthtrance'; import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names'; import { SHIPMENT_EXPRESSION_MAX_DEPTH, SHIPMENT_EXPRESSION_MAX_LEAVES } from '@/game/shipment-promises'; import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge'; import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; import { DUKE_VIDAL_ID } from '@/game/duke-vidal'; import { Input } from './ui/input'; import { Button } from './ui/button'; import { CardCountFields } from './truthtrance-card-count'; import { KnowledgeFactFields } from './truthtrance-knowledge'; import { ForceCountFields, forceCountInputError } from './truthtrance-force-count'; import { ShipmentClauseFields, invalidShipmentClause } from './shipment-claim-fields'; import { NoFieldValueFields } from './truthtrance-no-field-value'; import { parseNoFieldValueFact } from '@/game/truthtrance-no-field-value'; import { FaceDancerFactFields } from './truthtrance-face-dancer'; import { parseFaceDancerFact } from '@/game/truthtrance-face-dancer'; export function invalidTruthFact(fact: TruthFact, game: GameView, target: string): boolean { if (fact.kind === 'and' || fact.kind === 'or') { return fact.terms.some(term => invalidTruthFact(term, game, target)); } if (fact.kind === 'spice' || fact.kind === 'handCount' || fact.kind === 'handInventory') { return !Number.isSafeInteger(fact.value) || fact.value < 0; } if (fact.kind === 'noFieldValue') { try { parseNoFieldValueFact(fact); return false; } catch { return true; } } if (fact.kind === 'faceDancer') { try { parseFaceDancerFact(fact, [ ...game.allLeaders.filter((leader) => leader.id !== DUKE_VIDAL_ID && leader.id !== CHEAP_HERO_TRAITOR).map((leader) => leader.id), CHEAP_HERO_TRAITOR, ]); return false; } catch { return true; } } if (isKnowledgeFact(fact)) return !!knowledgeFactInputError(fact); if (fact.kind === 'forceCount') { return !!forceCountInputError(fact, game, game.players.find(player => player.id === target)!); } return false; } /** The same current-fact fields serve standalone and mixed questions. */ export function TruthFactFields({ value: fact, game, target, onChange }: { value: TruthFact; game: GameView; target: string; onChange: (fact: TruthFact) => void; }) { const id = useId(); const defaults: Record = { hand: { kind: 'hand', name: 'Shield' }, handCount: { kind: 'handCount', name: 'Shield', compare: 'gte', value: 2 }, handInventory: { kind: 'handInventory', category: 'all', compare: 'gte', value: 2 }, traitor: { kind: 'traitor', leader: game.allLeaders[0].id }, faceDancer: { kind: 'faceDancer', leader: game.allLeaders.find((leader) => leader.id !== DUKE_VIDAL_ID)?.id ?? CHEAP_HERO_TRAITOR }, predictionFaction: { kind: 'prediction', field: 'faction', faction: 'atreides' }, predictionTurn: { kind: 'prediction', field: 'turn', compare: 'gte', value: 1 }, stormDial: { kind: 'stormDial', compare: 'gte', value: 0 }, stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 }, spicePeek: { kind: 'spicePeek', value: 'worm' }, auctionPeek: { kind: 'auctionPeek', name: 'Shield' }, cacheLot: { kind: 'cacheLot', name: 'Shield' }, ixPoolPeek: { kind: 'ixPoolPeek', name: 'Shield' }, presciencePeek: { kind: 'presciencePeek', field: 'leader', name: 'None' }, spice: { kind: 'spice', compare: 'gte', value: 6 }, forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 }, noFieldValue: { kind: 'noFieldValue', value: 3 }, }; return <> {fact.kind === 'hand' ? : fact.kind === 'handCount' || fact.kind === 'handInventory' ? : fact.kind === 'faceDancer' ? : fact.kind === 'noFieldValue' ? : fact.kind === 'forceCount' ? player.id === target)!} onChange={onChange} /> : fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' || fact.kind === 'spicePeek' || fact.kind === 'auctionPeek' || fact.kind === 'cacheLot' || fact.kind === 'ixPoolPeek' || fact.kind === 'presciencePeek' ? : fact.kind === 'spice' ? <> onChange({ ...fact, value: event.currentTarget.valueAsNumber })} />

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.

{invalidTruthFact(fact, game, target) && } : fact.kind === 'traitor' ? : null} ; } const newFact = (): MixedShipmentExpression => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } }); const newShipment = (): MixedShipmentExpression => ({ kind: 'shipment', territory: 'carthag', minimum: 6 }); function factSize(fact: TruthFact): { leaves: number; depth: number } { if (fact.kind !== 'and' && fact.kind !== 'or') return { leaves: 1, depth: 0 }; const children = fact.terms.map(factSize); return { leaves: children.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(...children.map(child => child.depth)) }; } function expressionSize(expression: MixedShipmentExpression): { leaves: number; depth: number } { if (expression.kind === 'fact') return factSize(expression.fact); if (expression.kind === 'shipment') return { leaves: 1, depth: 0 }; const children = expression.terms.map(expressionSize); return { leaves: children.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(...children.map(child => child.depth)) }; } export function mixedShipmentInputError(expression: MixedShipmentExpression, game: GameView, target: string): string | null { const size = expressionSize(expression); if (size.leaves > SHIPMENT_EXPRESSION_MAX_LEAVES || size.depth > SHIPMENT_EXPRESSION_MAX_DEPTH) { return 'Use at most sixteen conditions and four nested groups.'; } let facts = 0; let shipments = 0; let invalid = false; const visit = (node: MixedShipmentExpression) => { if (node.kind === 'fact') { facts++; invalid ||= invalidTruthFact(node.fact, game, target); } else if (node.kind === 'shipment') { shipments++; invalid ||= invalidShipmentClause(node); } else { invalid ||= node.terms.length < 2; node.terms.forEach(visit); } }; visit(expression); if (!facts || !shipments) return 'Include both a current fact and a shipment-from-reserves condition.'; return invalid ? 'Resolve the marked fields; shipment minima must be whole numbers from one to twenty.' : null; } export function MixedShipmentFields({ id, value, game, target, disabled, onChange }: { id: string; value: MixedShipmentExpression; game: GameView; target: string; disabled: boolean; onChange: (expression: MixedShipmentExpression) => void; }) { const size = expressionSize(value); const render = (node: MixedShipmentExpression, path: string, depth: number, update: (next: MixedShipmentExpression) => void, remove?: () => void): ReactElement => { const group = node.kind === 'and' || node.kind === 'or'; const wrapAllowed = size.leaves < SHIPMENT_EXPRESSION_MAX_LEAVES && depth + expressionSize(node).depth < SHIPMENT_EXPRESSION_MAX_DEPTH; return
{group ? `Group ${path} · ${node.kind.toUpperCase()}` : `Condition ${path}`} {group ? <> {node.terms.map((term, index) => render(term, `${path}.${index + 1}`, depth + 1, next => update({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }), () => { const terms = node.terms.filter((_, i) => i !== index); update(terms.length === 1 ? terms[0] : { ...node, terms }); }))}
: <> {node.kind === 'fact' ? update({ kind: 'fact', fact })} /> : node.kind === 'shipment' ? update({ kind: 'shipment', ...clause })} /> : null} }
{remove && }
; }; return
Mixed current-fact / this-turn shipment claim

Current facts are evaluated when the player answers, not after shipment. They do not require keeping a card, spice or forces later. Every shipment condition refers to the same shipment from reserves this turn. No makes the whole grouped claim false, not necessarily every condition.

{size.leaves} / {SHIPMENT_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {SHIPMENT_EXPRESSION_MAX_DEPTH} nested groups

{render(value, '1', 0, onChange)}
; }