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
;
+ };
+ return ;
+}
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) => (
-
- ))}
-
- {invalidFact
- ? 'Resolve the marked fields before asking this question.'
- : truthQuestionText(question, leaderName, locationName)}
-
- >
+ question.kind === 'fact' && (
+
+ )
) : (
<>