From 8e2f2c6a95209e27d6cd25053ac80ba79f7ee178 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:50:28 +1100 Subject: [PATCH] Expose grouped Truthtrance current-fact questions --- components/truth-fact-expression-fields.tsx | 104 +++++ components/truth-fact-question.tsx | 43 +++ components/truthtrance.tsx | 47 +-- docs/CURRENT_STATUS.md | 42 ++- docs/IMPLEMENTATION_STATUS.md | 31 ++ docs/RULE_DECISIONS.md | 9 + docs/TRUTHTRANCE_CARD_COUNT.md | 46 +++ game/reference.ts | 33 +- game/truth-fact-expression.ts | 39 ++ game/truthtrance.ts | 14 +- tests/fixture-truth-fact-expression.ts | 175 +++++++++ tests/truth-fact-expression-controls.test.ts | 376 +++++++++++++++++++ tests/truth-fact-expression-engine.test.ts | 232 ++++++++++++ tests/truth-fact-expression.test.ts | 50 +++ 14 files changed, 1187 insertions(+), 54 deletions(-) create mode 100644 components/truth-fact-expression-fields.tsx create mode 100644 components/truth-fact-question.tsx create mode 100644 game/truth-fact-expression.ts create mode 100644 tests/fixture-truth-fact-expression.ts create mode 100644 tests/truth-fact-expression-controls.test.ts create mode 100644 tests/truth-fact-expression-engine.test.ts create mode 100644 tests/truth-fact-expression.test.ts diff --git a/components/truth-fact-expression-fields.tsx b/components/truth-fact-expression-fields.tsx new file mode 100644 index 00000000..f2a08ade --- /dev/null +++ b/components/truth-fact-expression-fields.tsx @@ -0,0 +1,104 @@ +'use client'; + +import type { ReactElement } from 'react'; +import type { GameView } from '@/game/engine'; +import type { TruthFact } from '@/game/truthtrance'; +import { + TRUTH_FACT_MAX_DEPTH, + TRUTH_FACT_MAX_NODES, + TRUTH_FACT_MAX_TERMS, + truthFactExpressionError, + truthFactExpressionSize, +} from '@/game/truth-fact-expression'; +import { TruthFactFields } from './mixed-shipment-fields'; +import { Button } from './ui/button'; + +const newFact = (): TruthFact => ({ kind: 'hand', name: 'Shield' }); + +/** Controlled structure editor. Leaf controls retain their existing validators. */ +export function TruthFactExpressionFields({ value, game, target, disabled, onChange }: { + value: TruthFact; + game: GameView; + target: string; + disabled: boolean; + onChange: (fact: TruthFact) => void; +}) { + const size = truthFactExpressionSize(value); + const commit = (next: TruthFact) => { + if (!disabled && !truthFactExpressionError(next)) onChange(next); + }; + const render = ( + node: TruthFact, + path: string, + replace: (next: TruthFact) => TruthFact, + remove?: () => TruthFact, + ): ReactElement => { + const group = node.kind === 'and' || node.kind === 'or'; + const allowed = (next: TruthFact) => !disabled && !truthFactExpressionError(replace(next)); + const change = (next: TruthFact) => commit(replace(next)); + const wrap = (kind: 'and' | 'or'): TruthFact => ({ kind, terms: [node, newFact()] }); + return
+ {group ? `Group ${path} · ${node.kind.toUpperCase()}` : `Current fact ${path}`} + {group ? <> + + {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 }); + }, + ))} +
+ + +
+ : } +
+ + + {remove && } +
+
; + }; + return
+ Current-fact expression +

+ AND requires all conditions; OR requires at least one. The player gives one aggregate answer + for the whole expression. No makes the whole expression false, not necessarily every condition. +

+

+ Current facts are evaluated when the player answers. They create no promise to keep cards, + spice or forces afterward. +

+

+ App limits: {TRUTH_FACT_MAX_TERMS} terms per group, {TRUTH_FACT_MAX_NODES} total clauses + including groups, and {TRUTH_FACT_MAX_DEPTH} levels of grouping. These are input limits, + not additional printed rules. +

+

{size.nodes} / {TRUTH_FACT_MAX_NODES} total clauses · {size.depth} / {TRUTH_FACT_MAX_DEPTH} levels of grouping

+ {render(value, '1', next => next)} +
; +} diff --git a/components/truth-fact-question.tsx b/components/truth-fact-question.tsx new file mode 100644 index 00000000..88228729 --- /dev/null +++ b/components/truth-fact-question.tsx @@ -0,0 +1,43 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { truthFactExpressionError } from '@/game/truth-fact-expression'; +import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; +import { truthQuestionText, type TruthFact, type TruthQuestion } from '@/game/truthtrance'; +import { invalidTruthFact } from './mixed-shipment-fields'; +import { TruthFactExpressionFields } from './truth-fact-expression-fields'; +import { Button } from './ui/button'; + +/** Current-fact authoring uses only the caller's entitled public projection. */ +export function TruthFactQuestion({ game, question, busy, act, onChange }: { + game: GameView; + question: Extract; + busy: boolean; + act: (action: Action) => void; + onChange: (fact: TruthFact) => void; +}) { + const ownsQuestion = () => !!game.me && game.players.some(player => player.id === game.me) && + game.truthtrance?.stage === 'ask' && game.truthtrance.queue[0]?.player === game.me; + const hasTarget = () => question.target !== game.me && game.players.some(player => player.id === question.target); + const inputError = () => truthFactExpressionError(question.fact) || + (hasTarget() && invalidTruthFact(question.fact, game, question.target) + ? 'Resolve the marked current-fact fields before asking.' : null); + const blocked = () => busy || !ownsQuestion() || !hasTarget() || !!inputError(); + const error = inputError(); + return <> + {hasTarget() ? { + if (!busy && ownsQuestion() && hasTarget() && !truthFactExpressionError(next)) onChange(next); + }} /> :

Choose another seated player for this question.

} +
+ {truthQuestionText(question, + id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : game.allLeaders.find(leader => leader.id === id)?.name ?? id, + id => game.combatLocations.find(location => location.id === id)?.name ?? id)} +
+ {error &&

{error}

} + + ; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index e9d693ce..36a1e2df 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -6,10 +6,9 @@ import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput } import { BattleClaimFields } from './battle-promises'; import { MixedShipmentFields, - TruthFactFields, - invalidTruthFact, mixedShipmentInputError, } from './mixed-shipment-fields'; +import { TruthFactQuestion } from './truth-fact-question'; import type { MixedShipmentExpression } from '@/game/mixed-shipment-question'; import type { PlanClaim } from '@/game/battle-promises'; import { useId, useState } from 'react'; @@ -78,14 +77,8 @@ export function Truthtrance({ const [scope, setScope] = useState<'fact' | 'currentTurn'>('fact'); const [text, setText] = useState(''); const [join, setJoin] = useState<'single' | 'and' | 'or'>('single'); - const [clauses, setClauses] = useState([ - { kind: 'hand', name: 'Shield' }, - { kind: 'hand', name: 'Snooper' }, - ]); + const [fact, setFact] = useState({ kind: 'hand', name: 'Shield' }); const factId = useId(); - const invalidFact = clauses - .slice(0, join === 'single' ? 1 : 2) - .some((fact) => invalidTruthFact(fact, g, target)); const leaderName = (id: string) => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' @@ -97,7 +90,7 @@ export function Truthtrance({ ? { kind, target, - fact: join === 'single' ? clauses[0] : { kind: join, terms: clauses }, + fact, } : kind === 'battlePlan' ? { @@ -445,33 +438,10 @@ export function Truthtrance({ ) &&

Select one of the combatants for this question.

} ) : kind === 'fact' ? ( - <> - - {clauses - .slice(0, join === 'single' ? 1 : 2) - .map((c, index) => ( -
- Fact {index + 1} - setClauses(clauses.map((old, n) => n === index ? next : old))} /> -
- ))} -

- {invalidFact - ? 'Resolve the marked fields before asking this question.' - : truthQuestionText(question, leaderName, locationName)} -

- + question.kind === 'fact' && ( + + ) ) : ( <>