diff --git a/README.md b/README.md
index b5101c35..7e208e71 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,10 @@ Above the board, **Treachery draw pile** and **Spice draw pile** show live card
counts separately from hands, auction cards and discards. See
[draw-pile counts](docs/DRAW_PILES.md).
+Truthtrance's **Current physical forces** question compares counters in reserves,
+the Tanks or an exact board location. It supports normal, elite and total counts
+where recorded; see [force questions](docs/TRUTHTRANCE_FORCE_FACTS.md).
+
After both battle plans are revealed, **Compare revealed battle plans** opens
the shared dials, leader portraits and played card faces. Inspect any component,
then return to the pending decision. See [battle components](docs/REVEALED_BATTLE_COMPONENTS.md).
diff --git a/components/truthtrance-force-count.tsx b/components/truthtrance-force-count.tsx
new file mode 100644
index 00000000..fd8a8c39
--- /dev/null
+++ b/components/truthtrance-force-count.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { useId } from 'react';
+import { gameTerritories, type MobileBoard } from '@/game/board';
+import { forceCountCounterError, parseForceCountFact, type ForceCountFact, type ForceCountPlayer } from '@/game/truthtrance-force-count';
+import { Input } from './ui/input';
+
+type CounterPlayer = Pick;
+
+export function forceCountInputError(value: ForceCountFact, game: MobileBoard, player: CounterPlayer): string | null {
+ try {
+ parseForceCountFact(value, gameTerritories(game).flatMap(t =>
+ t.sectors.map(sector => ({ territory: t.id, sector }))));
+ return forceCountCounterError(player, value.counter);
+ } catch (error) {
+ return error instanceof Error ? error.message : 'Choose a valid physical force count.';
+ }
+}
+
+export function ForceCountFields({ value, game, player, onChange }: {
+ value: ForceCountFact;
+ game: MobileBoard;
+ player: CounterPlayer;
+ onChange: (value: ForceCountFact) => void;
+}) {
+ const id = useId();
+ const territories = gameTerritories(game);
+ const zone = value.zone;
+ const selected = zone.kind === 'location' ? territories.find(t => t.id === zone.territory) : null;
+ const error = forceCountInputError(value, game, player);
+ return <>
+ Force pool {
+ const kind = event.target.value;
+ onChange({ ...value, zone: kind === 'location'
+ ? { kind, territory: territories[0].id, sector: territories[0].sectors[0] }
+ : { kind: kind as 'reserves' | 'tanks' } });
+ }}>
+ Reserves
+ Tleilaxu Tanks
+ One board location
+
+ {value.zone.kind === 'location' && <>
+ Force territory {
+ const target = territories.find(t => t.id === event.target.value)!;
+ onChange({ ...value, zone: { kind: 'location', territory: target.id, sector: target.sectors[0] } });
+ }}>{territories.map(t => {t.name} )}
+ Force sector {
+ if (value.zone.kind === 'location') onChange({ ...value, zone: { ...value.zone, sector: Number(event.target.value) } });
+ }}>{selected?.sectors.map(sector => {sector === 0 ? 'Interior / whole location' : `Sector ${sector}`} )}
+ >}
+ Counter type onChange({ ...value, counter: event.target.value as ForceCountFact['counter'] })}>
+ All physical force counters
+ Normal counters
+ Elite counters
+
+ Compare force count onChange({ ...value, compare: event.target.value as ForceCountFact['compare'] })}>
+ Exactly At least At most
+
+ Number of force counters onChange({ ...value, value: event.currentTarget.valueAsNumber })} />
+ Each physical counter counts once, including advisors. The total includes elites; the normal count excludes them. Concealed No-Field markers and their hidden values do not count. Reserves includes counters on native Homeworlds. This asks about current custody, not combat strength or a promise to keep forces there.
+ {!!forceCountCounterError(player, 'elite') && This table does not track this faction’s starred counters separately. Choose all physical force counters.
}
+ {error && {error}
}
+ >;
+}
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index 32d9c01d..e767150a 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -3,6 +3,7 @@ import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput }
import { BattleClaimFields } from './battle-promises';
import { CardCountFields } from './truthtrance-card-count';
import { KnowledgeFactFields } from './truthtrance-knowledge';
+import { ForceCountFields, forceCountInputError } from './truthtrance-force-count';
import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge';
import type { PlanClaim } from '@/game/battle-promises';
import { useId, useState } from 'react';
@@ -85,7 +86,8 @@ export function Truthtrance({
(!Number.isSafeInteger(fact.value) || fact.value < 0);
const invalidFact = clauses
.slice(0, join === 'single' ? 1 : 2)
- .some((fact) => invalidSpice(fact) || (isKnowledgeFact(fact) && !!knowledgeFactInputError(fact)));
+ .some((fact) => invalidSpice(fact) || (isKnowledgeFact(fact) && !!knowledgeFactInputError(fact)) ||
+ (fact.kind === 'forceCount' && !!forceCountInputError(fact, g, g.players.find(p => p.id === target)!)));
const leaderName = (id: string) =>
id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
@@ -442,6 +444,8 @@ export function Truthtrance({
? { kind: 'stormDial', compare: 'gte', value: 0 }
: e.target.value === 'stormForecast'
? { kind: 'stormForecast', compare: 'gte', value: 1 }
+ : e.target.value === 'forceCount'
+ ? { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 }
: e.target.value === 'spice'
? {
kind: 'spice',
@@ -472,6 +476,7 @@ export function Truthtrance({
Current personal spice
+ Current physical forces
{c.kind === 'hand' ? (
@@ -506,6 +511,9 @@ export function Truthtrance({
)
}
/>
+ ) : c.kind === 'forceCount' ? (
+ p.id === target)!}
+ onChange={next => setClauses(clauses.map((old, n) => n === index ? next : old))} />
) : c.kind === 'prediction' || c.kind === 'stormDial' || c.kind === 'stormForecast' ? (
{invalidFact
- ? 'Enter a valid whole amount before asking this question.'
+ ? 'Resolve the marked fields before asking this question.'
: truthQuestionText(question, leaderName)}
>
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 827e704b..ec6ea7a8 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -6,19 +6,20 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
-Latest delivered checkpoint: `3efda21`, [Basic Ixians with Leader Skills](IX_LEADER_SKILLS.md).
-Types, lint, **5,082 offline tests** and the production build passed; five
-complete two-through-six-player samples had no rejected actions. Browser setup,
-first Storm/HMS placement, mobile Suk rescue and substitution, and same-state
-refresh were checked. All 1,582 prior rooms and 3,350 seats remained intact;
-one QA room/seat brought the baseline to 1,583 rooms and 3,351 human seats.
+Latest delivered checkpoint: `d1bf33d`, [Basic CHOAM with Leader Skills](CHOAM_LEADER_SKILLS.md).
+Types, lint, **5,101 offline tests** and the production build passed; five
+complete two-through-six-player samples had no rejected actions across 3,067
+actions and 79 JSON restores. Browser setup, market Ghola revival, private
+skill refresh, mobile assignment and exact sale settlement were checked.
+All 1,583 prior rooms and 3,351 seats remained intact; one QA room/seat brought
+the baseline to 1,584 rooms and 3,352 human seats.
Screenshot capture timed out; full visual acceptance remains open.
-Current work [connects ordinary Leader Skill actions to Basic CHOAM](CHOAM_LEADER_SKILLS.md),
-using its existing genuine setup. Exact 35-card Treachery and fourteen-skill inventories,
-native Worthless-card powers, mandatory battle disposal and interrupted market
-Ghola revival are receiving integration and saved-continuation checks. Advanced
-Auditor and Richese/other combinations retain their existing separate contracts.
+Current work adds [Truthtrance physical-force questions](TRUTHTRANCE_FORCE_FACTS.md):
+normal, elite or total counters in reserves, the Tanks or one board location.
+Existing compound questions, private answers, card retirement and saved history
+are reused. These are present facts, without hidden No-Field values or future
+movement constraints. Saved facts are revalidated before actions or projection.
The [Sandmaster/HMS source comparison](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility)
found no official clarification that passengers remaining in the interior
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 1d508259..b57c1000 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,14 @@
# Dune implementation status
+## 21 September 2026 — Truthtrance physical-force questions
+
+Current force facts compare normal, elite or total physical counters in reserves,
+the Tanks or an exact live board location. Shared controls and validation retain
+typed-counter availability, exclude concealed No-Field values and reuse private
+compound answers, legal AI replies, history and card disposal. Restored fact
+questions are reparsed; current answers do not constrain later force actions.
+See the [scope and evidence](TRUTHTRANCE_FORCE_FACTS.md).
+
## 21 September 2026 — Basic CHOAM ordinary Leader Skill integration
The existing genuine CHOAM setup now shares ordinary skill action guards for
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 8846ca86..ad5be40d 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -48,6 +48,13 @@ expression. One eventual shipment or skip is evaluated against every separate
promise. See [compound shipment promises](COMPOUND_SHIPMENT_PROMISES.md); mixed
present/future predicates and existing timing/module gates remain unfinished.
+Current Truthtrance force facts count physical counters in reserves, the Tanks
+or one exact live board location. Totals include elites; normal counts subtract
+them. Advisors remain counters, and concealed No-Field markers/values are excluded.
+These are current observations, not future obligations; no typed distribution
+is invented when Basic lacks separate starred-counter tracking. See the
+[force-fact source composition](TRUTHTRANCE_FORCE_FACTS.md).
+
## Pending interpretations: preserve existing questions
These are unresolved in the cited records. Do not infer an answer from elapsed time, a fixture, a related card or this index. Check subsequent recorded user/source decisions before proceeding; do not send duplicate questions.
diff --git a/docs/TRUTHTRANCE_FORCE_FACTS.md b/docs/TRUTHTRANCE_FORCE_FACTS.md
new file mode 100644
index 00000000..32ebb2d9
--- /dev/null
+++ b/docs/TRUTHTRANCE_FORCE_FACTS.md
@@ -0,0 +1,75 @@
+# Truthtrance physical-force facts
+
+Development prototype, 21 September 2026. Structured questions now compare the
+respondent's current physical counters in reserves, the Tanks or one exact board
+location. This extends the existing fact family; it does not complete arbitrary
+prose interpretation, future commitments or all Truthtrance interactions.
+
+## Source and semantics
+
+The November 2020 GF9 FAQ p.8 permits game-related yes/no questions and AND/OR
+combinations. The existing [nonbattle source audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md)
+identifies current force/reserve/Tank counts as answerable facts. This prototype
+uses the same current-custody distinction as [personal spice facts](TRUTHTRANCE_SPICE_FACTS.md).
+No new faction advantage, hidden-information disclosure or future promise is inferred.
+
+Each physical counter counts once. `total` includes the elite subset; `normal`
+subtracts it, and `elite` counts that subset directly. Combat strength, support,
+Suboid/Cyborg multipliers and other temporary bonuses do not change these values.
+Advisors are physical counters and are included without a separate fighting-state
+question. A concealed No-Field is a separate marker; neither its presence nor
+its private denomination adds physical counters at the queried location.
+
+Reserves includes the native Homeworld breakdown already represented by the
+player's aggregate reserve pool. The Tanks is a separate pool. Board questions
+select an exact territory and sector, including an available HMS interior or
+revealed Discovery location. They do not aggregate a whole multi-sector territory
+or query individual Homeworld populations; those question forms remain separate
+work. Changing the HMS pointer does not move the counters inside to the outside
+territory.
+
+Basic special-force factions may lack a tracked normal/starred split. Their
+aggregate total is answerable; typed comparisons are unavailable rather than
+inventing a distribution. Ordinary factions without special counters have zero
+elite counters. The same public guard feeds parsing and controls.
+
+## Connected flow and saved behavior
+
+Choose **Current physical forces**, then the pool, optional board location,
+counter type, comparison and nonnegative whole threshold. Combine this clause
+with another supported fact using AND or OR. The preview names the exact scope;
+invalid values or unavailable typed splits disable submission with an explanation.
+
+The authoritative answer uses custody at the answer action. Only the respondent
+receives the private expected answer; the final response discloses the aggregate
+Yes/No result, without identifying which compound clause matched. Existing legal
+AI response paths consume that same private projection. No strategy tuning is added.
+
+New saved fact questions are reparsed before actions and view projection. A
+malformed threshold, counter, location or nested clause cannot silently become a
+different question after restoration. Historical answers remain observations:
+later legal revival or movement does not rewrite them or violate a commitment.
+The original interrupted decision resumes after mandatory Truthtrance disposal.
+
+## Verification
+
+```sh
+npm test -- truthtrance-force
+npm test -- truthtrance
+```
+
+Pure comparisons, genuine setup and conserved game scenarios, component
+validation, all four legal AI answer paths, malformed saved questions and
+authenticated concurrent recovery cover this boundary. Private source-bound
+checkpoint reports record final broad checks, browser and preservation results.
+Full mode, combination and visual acceptance remain open.
+
+The browser check used genuine Basic Atreides/Emperor setup and relocated only
+the two existing Truthtrance Cards for focused testing; forces stayed unchanged.
+A negative count disabled submission. A mobile compound question compared twenty
+Emperor reserves and zero counters in Carthag sector 11, received the AI's Yes
+and resumed Storm after one discard. A separately staged real question asked the
+human about ten normal counters in Arrakeen sector 10. Its pending private answer
+survived refresh with the saved row unchanged, then published Yes and retired
+the second card once. The 390-pixel layout had no horizontal overflow. Existing
+screenshot capture failures leave full visual acceptance open.
diff --git a/game/reference.ts b/game/reference.ts
index dbb98d6a..84d6e8ee 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -2408,6 +2408,28 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['bidding', 'battle', 'advanced-combat', 'special-karama'],
},
+ {
+ id: 'truthtrance-forces',
+ title: 'Truthtrance: physical force counts',
+ category: 'Cards',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'Ask about current physical counters in reserves, the Tanks or an exact board location.',
+ steps: [
+ 'Choose Current physical forces, then the pool, counter type and an Exactly, At least or At most comparison. A board question names both territory and sector. Available HMS interiors and revealed Discovery locations are separate from their outside territories.',
+ 'Every physical counter counts once, including advisors. The total includes elites, and the normal count excludes them. Combat bonuses, concealed No-Field markers and their hidden force values do not enter the count.',
+ 'Reserves includes the native Homeworld reserve breakdown. These controls do not query individual Homeworld populations. A Basic faction whose starred counters are not tracked separately supports only the aggregate total.',
+ 'Combine the question with another supported fact using AND or OR. Only the respondent sees the expected answer; the published result does not identify which clause matched. Later legal movement or revival does not change this historical answer or break a promise.',
+ ],
+ related: ['card-truthtrance', 'force-counters', 'truthtrance-spice', 'privacy'],
+ checklist: [
+ { area: 'Implementation', status: 'Implemented', detail: 'Physical pool comparisons, exact live locations, typed-counter availability and saved fact revalidation are connected.', evidence: ['game/truthtrance-force-count.ts', 'game/truthtrance.ts'] },
+ { area: 'Player controls', status: 'Implemented', detail: 'Pool, territory/sector, counter type and numeric comparison share validation with the server.', evidence: ['components/truthtrance-force-count.tsx', 'components/truthtrance.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All profiles answer the shared private projection legally. Broader questioning strategy and tuning remain deferred.', evidence: ['game/bots.ts', 'tests/truthtrance-force-engine.test.ts'] },
+ { area: 'Documentation', status: 'Implemented', detail: 'Source composition, present custody, marker exclusions and remaining question forms are explicit.', evidence: ['docs/TRUTHTRANCE_FORCE_FACTS.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Genuine setup, typed pools, private compounds, live board boundaries, later legal actions, malformed saves, controls and authenticated concurrent answers have focused checks.', evidence: ['tests/truthtrance-force-count.test.ts', 'tests/truthtrance-force-engine.test.ts', 'tests/truthtrance-force-controls.test.tsx', 'tests/truthtrance-force-recovery.test.ts'] },
+ ],
+ },
{
id: 'truthtrance-spice',
title: 'Truthtrance: current spice questions',
@@ -2534,6 +2556,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'card-truthtrance',
'truthtrance-card-count',
'truthtrance-spice',
+ 'truthtrance-forces',
'privacy',
],
checklist: [
@@ -2679,6 +2702,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'battle',
'privacy',
'truthtrance-spice',
+ 'truthtrance-forces',
'truthtrance-card-count',
'truthtrance-hand-inventory',
'truthtrance-shipment',
diff --git a/game/truthtrance-force-count.ts b/game/truthtrance-force-count.ts
new file mode 100644
index 00000000..845cfa0a
--- /dev/null
+++ b/game/truthtrance-force-count.ts
@@ -0,0 +1,202 @@
+import { location, territory } from './board';
+import type { FactionId } from './catalog';
+
+export type ForceCountFact = {
+ kind: 'forceCount';
+ zone:
+ | { kind: 'reserves' }
+ | { kind: 'tanks' }
+ | { kind: 'location'; territory: string; sector: number };
+ counter: 'total' | 'normal' | 'elite';
+ compare: 'eq' | 'gte' | 'lte';
+ value: number;
+};
+
+export type ForceCountPlayer = {
+ faction: FactionId;
+ reserves: number;
+ tanks: number;
+ forces: Readonly>;
+ elites?: Readonly<{
+ reserves: number;
+ tanks: number;
+ forces: Readonly>;
+ }>;
+};
+
+type ValidLocation = Readonly<{ territory: string; sector: number }>;
+
+const splitFactions = new Set(['emperor', 'fremen', 'ixians']);
+
+function record(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function exactKeys(
+ value: Record,
+ expected: readonly string[],
+) {
+ const actual = Object.keys(value).sort();
+ const wanted = [...expected].sort();
+ return (
+ actual.length === wanted.length &&
+ actual.every((key, index) => key === wanted[index])
+ );
+}
+
+function count(value: unknown, label: string) {
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
+ throw new Error(`${label} must be a nonnegative safe whole number.`);
+ return value;
+}
+
+/** Parse a current public force fact against the room's actual board locations. */
+export function parseForceCountFact(
+ value: unknown,
+ validLocations: readonly ValidLocation[],
+): ForceCountFact {
+ if (
+ !record(value) ||
+ !exactKeys(value, ['kind', 'zone', 'counter', 'compare', 'value']) ||
+ value.kind !== 'forceCount'
+ )
+ throw new Error('Choose only a current physical force-count fact.');
+ if (
+ value.counter !== 'total' &&
+ value.counter !== 'normal' &&
+ value.counter !== 'elite'
+ )
+ throw new Error('Choose total, normal, or elite physical forces.');
+ if (
+ value.compare !== 'eq' &&
+ value.compare !== 'gte' &&
+ value.compare !== 'lte'
+ )
+ throw new Error(
+ 'Compare force counts using exactly, at least, or at most.',
+ );
+ const threshold = count(value.value, 'The force threshold');
+ const zone = value.zone;
+ if (!record(zone)) throw new Error('Choose a current force pool.');
+ let parsedZone: ForceCountFact['zone'];
+ if (zone.kind === 'reserves' || zone.kind === 'tanks') {
+ if (!exactKeys(zone, ['kind']))
+ throw new Error('Reserve and Tanks facts cannot name a board location.');
+ parsedZone = { kind: zone.kind };
+ } else if (zone.kind === 'location') {
+ if (
+ !exactKeys(zone, ['kind', 'territory', 'sector']) ||
+ typeof zone.territory !== 'string' ||
+ !Number.isSafeInteger(zone.sector) ||
+ !validLocations.some(
+ (candidate) =>
+ candidate.territory === zone.territory &&
+ candidate.sector === zone.sector,
+ )
+ )
+ throw new Error('Choose an exact current board territory and sector.');
+ parsedZone = {
+ kind: 'location',
+ territory: zone.territory,
+ sector: zone.sector as number,
+ };
+ } else {
+ throw new Error('Choose reserves, the Tanks, or a current board location.');
+ }
+ return {
+ kind: 'forceCount',
+ zone: parsedZone,
+ counter: value.counter,
+ compare: value.compare,
+ value: threshold,
+ };
+}
+
+/** Public reason that denomination-specific facts cannot be answered safely. */
+export function forceCountCounterError(
+ player: Pick,
+ counter: ForceCountFact['counter'],
+): string | null {
+ return counter !== 'total' &&
+ splitFactions.has(player.faction) &&
+ player.elites === undefined
+ ? 'Normal and elite force counts are unavailable without a tracked special-force split.'
+ : null;
+}
+
+function chosenPool(player: ForceCountPlayer, fact: ForceCountFact) {
+ const eliteState = player.elites;
+ if (eliteState !== undefined && !record(eliteState))
+ throw new Error('The tracked special-force split is malformed.');
+
+ let totalValue: unknown;
+ let eliteValue: unknown = 0;
+ if (fact.zone.kind === 'reserves') {
+ totalValue = player.reserves;
+ if (eliteState !== undefined) eliteValue = eliteState.reserves;
+ } else if (fact.zone.kind === 'tanks') {
+ totalValue = player.tanks;
+ if (eliteState !== undefined) eliteValue = eliteState.tanks;
+ } else {
+ if (!record(player.forces))
+ throw new Error('The selected board force pool is malformed.');
+ const key = location(fact.zone.territory, fact.zone.sector);
+ totalValue = player.forces[key] ?? 0;
+ if (eliteState !== undefined) {
+ if (!record(eliteState.forces))
+ throw new Error('The selected elite board force pool is malformed.');
+ eliteValue = eliteState.forces[key] ?? 0;
+ }
+ }
+ const total = count(totalValue, 'The selected physical force count');
+ const elite = count(eliteValue, 'The selected elite force count');
+ if (elite > total)
+ throw new Error(
+ 'The selected elite force count exceeds its physical force pool.',
+ );
+ return { total, elite };
+}
+
+/** Compare one current physical pool; unrelated pools are deliberately unread. */
+export function forceCountFactMatches(
+ player: ForceCountPlayer,
+ fact: ForceCountFact,
+): boolean {
+ const unavailable = forceCountCounterError(player, fact.counter);
+ if (unavailable) throw new Error(unavailable);
+ const pool = chosenPool(player, fact);
+ const actual =
+ fact.counter === 'total'
+ ? pool.total
+ : fact.counter === 'elite'
+ ? pool.elite
+ : pool.total - pool.elite;
+ return fact.compare === 'eq'
+ ? actual === fact.value
+ : fact.compare === 'gte'
+ ? actual >= fact.value
+ : actual <= fact.value;
+}
+
+export function forceCountFactText(fact: ForceCountFact): string {
+ const comparison =
+ fact.compare === 'eq'
+ ? 'exactly'
+ : fact.compare === 'gte'
+ ? 'at least'
+ : 'at most';
+ const plural = fact.value === 1 ? 'force' : 'forces';
+ const counters =
+ fact.counter === 'total'
+ ? `physical ${plural}`
+ : fact.counter === 'normal'
+ ? `normal ${plural}`
+ : `elite ${plural}`;
+ const zone =
+ fact.zone.kind === 'reserves'
+ ? 'in your reserves'
+ : fact.zone.kind === 'tanks'
+ ? 'in the Tleilaxu Tanks'
+ : `in ${territory(fact.zone.territory).name}, sector ${fact.zone.sector}`;
+ return `you currently have ${comparison} ${fact.value} ${counters} ${zone}`;
+}
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 61af62c8..a0a2ca7d 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -24,7 +24,8 @@ import {
planClaimText,
type PlanClaim,
} from './battle-promises';
-import { territory } from './board';
+import { gameTerritories, territory } from './board';
+import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCountCounterError, type ForceCountFact, type ForceCountPlayer } from './truthtrance-force-count';
import type { Action, Game, Player } from './engine';
import { treacheryDeck } from './cards';
import { CHEAP_HERO_TRAITOR } from './traitors';
@@ -36,6 +37,7 @@ export type TruthFact =
| KnowledgeFact
| CardCountFact
| HandInventoryFact
+ | ForceCountFact
| { kind: 'hand'; name: string }
| { kind: 'traitor'; leader: string }
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
@@ -87,6 +89,7 @@ const record = (value: unknown): Record => {
function parseFact(
g: Game,
value: unknown,
+ respondent: Player,
depth = 0,
budget = { remaining: 16 },
): TruthFact {
@@ -95,6 +98,17 @@ function parseFact(
'Truthtrance supports at most 16 clauses and four levels of grouping.',
);
const v = record(value);
+ if (v.kind === 'forceCount') {
+ try {
+ const fact = parseForceCountFact(v, gameTerritories(g).flatMap(t =>
+ t.sectors.map(sector => ({ territory: t.id, sector }))));
+ const unavailable = forceCountCounterError(respondent, fact.counter);
+ check(!unavailable, unavailable ?? 'Choose a recorded physical counter type.');
+ return fact;
+ } catch (error) {
+ throw new TruthError(error instanceof Error ? error.message : String(error));
+ }
+ }
if (isKnowledgeFact(v)) {
try { return parseKnowledgeFact(v); }
catch (error) { throw new TruthError(error instanceof Error ? error.message : String(error)); }
@@ -148,7 +162,7 @@ function parseFact(
);
return {
kind: v.kind,
- terms: v.terms.map((term) => parseFact(g, term, depth + 1, budget)),
+ terms: v.terms.map((term) => parseFact(g, term, respondent, depth + 1, budget)),
};
}
/** Shared battle discs are valid plan identities, but never expand the traitor inventory. */
@@ -208,7 +222,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
};
}
if (v.kind === 'fact')
- return { kind: 'fact', target: v.target, fact: parseFact(g, v.fact) };
+ return { kind: 'fact', target: v.target, fact: parseFact(g, v.fact, g.players.find(p => p.id === v.target)!) };
check(
v.kind === 'freeform' &&
typeof v.text === 'string' &&
@@ -267,6 +281,13 @@ export function validateSavedTruthtrance(g: Game) {
'The saved Truthtrance must retain each committed physical card in its holder’s hand.',
);
}
+ if (window?.question?.kind === 'fact') {
+ check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
+ 'The saved fact question must retain its asking player and answer stage.');
+ check(JSON.stringify(parseQuestion(g, window.queue[0].player, window.question)) === JSON.stringify(window.question),
+ 'The saved fact question no longer matches its recorded form.');
+ return;
+ }
if (window?.question?.kind !== 'shipment') return;
check(
(window.stage === 'answer' || window.stage === 'unknown') &&
@@ -303,11 +324,13 @@ export function queuedTruthCardMatches(
}
/** A fact answer never returns which disjunct matched or any underlying private cards. */
export function truthFactAnswer(
- p: Pick,
+ p: Pick & Partial,
fact: TruthFact,
knowledge?: TruthKnowledge,
): TruthAnswer {
if (isKnowledgeFact(fact)) return knowledgeFactAnswer(fact, knowledge);
+ if (fact.kind === 'forceCount')
+ return forceCountFactMatches(p as ForceCountPlayer, fact) ? 'yes' : 'no';
if (fact.kind === 'handInventory')
return handInventoryFactMatches(p.hand, fact) ? 'yes' : 'no';
if (fact.kind === 'handCount')
@@ -352,6 +375,7 @@ export function truthQuestionText(
return `In this battle in ${territory(q.territory).name}, will it be true that ${planClaimText(q.claim, leaderName)}?`;
const clause = (f: TruthFact): string => {
if (isKnowledgeFact(f)) return knowledgeFactText(f);
+ if (f.kind === 'forceCount') return forceCountFactText(f);
if (f.kind === 'handInventory') return handInventoryFactText(f);
if (f.kind === 'handCount') return cardCountFactText(f);
if (f.kind === 'hand') return `you hold ${f.name}`;
diff --git a/tests/truthtrance-force-controls.test.tsx b/tests/truthtrance-force-controls.test.tsx
new file mode 100644
index 00000000..ac0c98af
--- /dev/null
+++ b/tests/truthtrance-force-controls.test.tsx
@@ -0,0 +1,55 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { ForceCountFields, forceCountInputError } from '../components/truthtrance-force-count';
+import type { ForceCountFact, ForceCountPlayer } from '../game/truthtrance-force-count';
+import type { MobileBoard } from '../game/board';
+
+const fact: ForceCountFact = { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 };
+const player: Pick = { faction: 'atreides' };
+function markup(value = fact, game: MobileBoard = {}, target = player) {
+ return renderToStaticMarkup(createElement(ForceCountFields, { value, game, player: target, onChange() {} }));
+}
+
+void test('force fact controls name physical pools and comparisons without revealing a hidden marker value', () => {
+ const html = markup();
+ for (const value of ['reserves', 'tanks', 'location', 'total', 'normal', 'elite', 'eq', 'gte', 'lte'])
+ assert.match(html, new RegExp(`value="${value}"`));
+ assert.match(html, /Number of force counters/);
+ assert.match(html, /Each physical counter counts once, including advisors/);
+ assert.match(html, /Concealed No-Field markers and their hidden values do not count/);
+ assert.match(html, /Reserves includes counters on native Homeworlds/);
+ assert.match(html, /min="0"/); assert.match(html, /max="9007199254740991"/);
+});
+
+void test('location choices use the room board and the selected territory sectors', () => {
+ const html = markup({ ...fact, zone: { kind: 'location', territory: 'carthag', sector: 11 } });
+ assert.match(html, /Force territory/); assert.match(html, /Carthag/);
+ assert.match(html, /Force sector/); assert.match(html, /value="11" selected/);
+ assert.doesNotMatch(html, /value="hidden_mobile_stronghold"/);
+ assert.doesNotMatch(html, /value="jacurutu"/);
+ assert.ok(forceCountInputError({ ...fact, zone: { kind: 'location', territory: 'carthag', sector: 99 } }, {}, player));
+});
+
+void test('an absent special-counter split disables typed comparisons without inventing counts', () => {
+ const basic = { faction: 'emperor' } as const;
+ assert.equal(forceCountInputError(fact, {}, basic), null);
+ assert.ok(forceCountInputError({ ...fact, counter: 'elite' }, {}, basic));
+ const html = markup({ ...fact, counter: 'elite' }, {}, basic);
+ assert.match(html, /value="normal" disabled/); assert.match(html, /value="elite" disabled/);
+ assert.match(html, /role="alert"/); assert.match(html, /does not track this faction/);
+ const split = { faction: 'emperor' as const, elites: { reserves: 3, tanks: 1, forces: {} } };
+ assert.equal(forceCountInputError({ ...fact, counter: 'elite' }, {}, split), null);
+ assert.doesNotMatch(markup({ ...fact, counter: 'elite' }, {}, split), /role="alert"/);
+});
+
+void test('invalid numeric input stays visible and blocks the shared form validation', () => {
+ for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
+ const input = { ...fact, value };
+ assert.ok(forceCountInputError(input, {}, player));
+ assert.match(markup(input), /aria-invalid="true"/);
+ assert.match(markup(input), /role="alert"/);
+ }
+ assert.equal(forceCountInputError({ ...fact, value: 0 }, {}, player), null);
+});
diff --git a/tests/truthtrance-force-count.test.ts b/tests/truthtrance-force-count.test.ts
new file mode 100644
index 00000000..f245022d
--- /dev/null
+++ b/tests/truthtrance-force-count.test.ts
@@ -0,0 +1,242 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ forceCountCounterError,
+ forceCountFactMatches,
+ forceCountFactText,
+ parseForceCountFact,
+ type ForceCountFact,
+ type ForceCountPlayer,
+} from '../game/truthtrance-force-count';
+
+const locations = [
+ { territory: 'arrakeen', sector: 10 },
+ { territory: 'imperial_basin', sector: 9 },
+] as const;
+
+function fact(
+ zone: ForceCountFact['zone'],
+ counter: ForceCountFact['counter'] = 'total',
+ compare: ForceCountFact['compare'] = 'eq',
+ value = 0,
+): ForceCountFact {
+ return { kind: 'forceCount', zone, counter, compare, value };
+}
+
+void test('parser accepts exact live pools and rejects extra, mutually exclusive, unknown, and unsafe fields immutably', () => {
+ const input = {
+ kind: 'forceCount',
+ zone: { kind: 'location', territory: 'arrakeen', sector: 10 },
+ counter: 'elite',
+ compare: 'gte',
+ value: 2,
+ };
+ const before = structuredClone(input);
+ assert.deepEqual(parseForceCountFact(input, locations), input);
+ assert.deepEqual(input, before);
+ for (const zone of [
+ { kind: 'reserves' },
+ { kind: 'tanks' },
+ { kind: 'location', territory: 'imperial_basin', sector: 9 },
+ ])
+ assert.deepEqual(
+ parseForceCountFact({ ...input, zone }, locations).zone,
+ zone,
+ );
+
+ for (const malformed of [
+ { ...input, privateHint: true },
+ { ...input, zone: { kind: 'reserves', territory: 'arrakeen' } },
+ {
+ ...input,
+ zone: {
+ kind: 'location',
+ territory: 'arrakeen',
+ sector: 10,
+ pool: 'reserves',
+ },
+ },
+ { ...input, zone: { kind: 'location', territory: 'arrakeen', sector: 9 } },
+ { ...input, zone: { kind: 'location', territory: 'unknown', sector: 10 } },
+ {
+ ...input,
+ zone: { kind: 'location', territory: 'arrakeen', sector: 10.5 },
+ },
+ { ...input, zone: { kind: 'homeworld', territory: 'caladan', sector: 0 } },
+ ])
+ assert.throws(() => parseForceCountFact(malformed, locations));
+
+ for (const counter of ['special', '', 1, null, undefined])
+ assert.throws(() => parseForceCountFact({ ...input, counter }, locations));
+ for (const compare of ['gt', '=', '', 1, null, undefined])
+ assert.throws(() => parseForceCountFact({ ...input, compare }, locations));
+ for (const value of [
+ -1,
+ 0.5,
+ NaN,
+ Infinity,
+ Number.MAX_SAFE_INTEGER + 1,
+ '2',
+ null,
+ ])
+ assert.throws(() => parseForceCountFact({ ...input, value }, locations));
+});
+
+void test('current totals include elites while normal counts subtract the tracked physical subset in every pool', () => {
+ const player: ForceCountPlayer = {
+ faction: 'ixians',
+ reserves: 10,
+ tanks: 4,
+ forces: { 'arrakeen:10': 6 },
+ elites: {
+ reserves: 3,
+ tanks: 1,
+ forces: { 'arrakeen:10': 2 },
+ },
+ };
+ const before = structuredClone(player);
+ for (const [zone, totals] of [
+ [{ kind: 'reserves' }, [10, 7, 3]],
+ [{ kind: 'tanks' }, [4, 3, 1]],
+ [{ kind: 'location', territory: 'arrakeen', sector: 10 }, [6, 4, 2]],
+ [{ kind: 'location', territory: 'imperial_basin', sector: 9 }, [0, 0, 0]],
+ ] as const)
+ for (const [index, counter] of (
+ ['total', 'normal', 'elite'] as const
+ ).entries()) {
+ const value = totals[index];
+ assert.equal(
+ forceCountFactMatches(player, fact(zone, counter, 'eq', value)),
+ true,
+ );
+ assert.equal(
+ forceCountFactMatches(player, fact(zone, counter, 'gte', value)),
+ true,
+ );
+ assert.equal(
+ forceCountFactMatches(player, fact(zone, counter, 'lte', value)),
+ true,
+ );
+ assert.equal(
+ forceCountFactMatches(player, fact(zone, counter, 'gte', value + 1)),
+ false,
+ );
+ }
+ assert.deepEqual(player, before);
+});
+
+void test('ordinary factions need no fabricated elite ledger, while split factions fail closed for denomination questions', () => {
+ const ordinary: ForceCountPlayer = {
+ faction: 'atreides',
+ reserves: 8,
+ tanks: 2,
+ forces: { 'arrakeen:10': 5 },
+ };
+ assert.equal(forceCountCounterError(ordinary, 'normal'), null);
+ assert.equal(
+ forceCountFactMatches(
+ ordinary,
+ fact({ kind: 'reserves' }, 'normal', 'eq', 8),
+ ),
+ true,
+ );
+ assert.equal(
+ forceCountFactMatches(
+ ordinary,
+ fact({ kind: 'reserves' }, 'elite', 'eq', 0),
+ ),
+ true,
+ );
+
+ const untracked: ForceCountPlayer = { ...ordinary, faction: 'emperor' };
+ assert.equal(forceCountCounterError(untracked, 'total'), null);
+ assert.match(forceCountCounterError(untracked, 'elite')!, /unavailable/);
+ assert.equal(
+ forceCountFactMatches(
+ untracked,
+ fact({ kind: 'reserves' }, 'total', 'eq', 8),
+ ),
+ true,
+ );
+ assert.throws(
+ () =>
+ forceCountFactMatches(
+ untracked,
+ fact({ kind: 'reserves' }, 'normal', 'eq', 8),
+ ),
+ /unavailable/,
+ );
+});
+
+void test('matcher validates only the queried pool and rejects malformed selected totals or elite subsets', () => {
+ const base: ForceCountPlayer = {
+ faction: 'ixians',
+ reserves: 7,
+ tanks: 2,
+ forces: { 'arrakeen:10': 4 },
+ elites: { reserves: 2, tanks: 1, forces: { 'arrakeen:10': 1 } },
+ };
+ const unrelated = {
+ ...structuredClone(base),
+ tanks: -3,
+ forces: null,
+ } as unknown as ForceCountPlayer;
+ assert.equal(
+ forceCountFactMatches(
+ unrelated,
+ fact({ kind: 'reserves' }, 'normal', 'eq', 5),
+ ),
+ true,
+ );
+
+ for (const reserves of [-1, 1.5, NaN, Number.MAX_SAFE_INTEGER + 1])
+ assert.throws(() =>
+ forceCountFactMatches({ ...base, reserves }, fact({ kind: 'reserves' })),
+ );
+ assert.throws(
+ () =>
+ forceCountFactMatches(
+ { ...base, elites: { ...base.elites!, reserves: 8 } },
+ fact({ kind: 'reserves' }, 'total', 'eq', 7),
+ ),
+ /exceeds/,
+ );
+ assert.throws(
+ () =>
+ forceCountFactMatches(
+ { ...base, elites: { ...base.elites!, forces: { 'arrakeen:10': 5 } } },
+ fact({ kind: 'location', territory: 'arrakeen', sector: 10 }, 'elite'),
+ ),
+ /exceeds/,
+ );
+ assert.throws(
+ () =>
+ forceCountFactMatches(
+ { ...base, elites: { ...base.elites!, tanks: -1 } },
+ fact({ kind: 'tanks' }, 'total'),
+ ),
+ /nonnegative/,
+ );
+});
+
+void test('question text uses printed territory display and states current pool, denomination, and comparison', () => {
+ assert.equal(
+ forceCountFactText(fact({ kind: 'reserves' }, 'total', 'eq', 1)),
+ 'you currently have exactly 1 physical force in your reserves',
+ );
+ assert.equal(
+ forceCountFactText(fact({ kind: 'tanks' }, 'normal', 'lte', 2)),
+ 'you currently have at most 2 normal forces in the Tleilaxu Tanks',
+ );
+ assert.equal(
+ forceCountFactText(
+ fact(
+ { kind: 'location', territory: 'arrakeen', sector: 10 },
+ 'elite',
+ 'gte',
+ 3,
+ ),
+ ),
+ 'you currently have at least 3 elite forces in Arrakeen, sector 10',
+ );
+});
diff --git a/tests/truthtrance-force-engine.test.ts b/tests/truthtrance-force-engine.test.ts
new file mode 100644
index 00000000..6113b943
--- /dev/null
+++ b/tests/truthtrance-force-engine.test.ts
@@ -0,0 +1,288 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
+import type { TruthFact } from '../game/truthtrance';
+import { knowledgeGame, knowledgePhysical } from './truthtrance-knowledge-fixture';
+import { completedIxSkillsGame, assertIxSkillsCustody } from './ix-skills-fixture';
+import { discoveryFixture, enterDiscoveryCollection } from './fixture-discovery';
+import { homeworldRevivalFixture, revivalInventory } from './fixture-homeworld-revival';
+
+type ForceFact = Extract;
+const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
+const player = (game: Game, id: string) => game.players.find(candidate => candidate.id === id)!;
+const act = (game: Game, owner: string, action: Action) => reload(applyAction(reload(game), owner, action));
+const fact = (zone: ForceFact['zone'], counter: ForceFact['counter'], value: number,
+ compare: ForceFact['compare'] = 'eq'): ForceFact => ({ kind: 'forceCount', zone, counter, compare, value });
+
+function reject(game: Game, owner: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, owner, action));
+ assert.deepEqual(game, before);
+}
+
+/** Move an existing physical card into the asker's hand; never replace a deck. */
+function holdTruth(game: Game, owner: string) {
+ if (player(game, owner).hand.some(card => card.effect === 'truthtrance')) return;
+ const source = [game.deck, ...game.players.map(candidate => candidate.hand)]
+ .find(cards => cards.some(card => card.effect === 'truthtrance'))!;
+ assert.ok(source);
+ const index = source.findIndex(card => card.effect === 'truthtrance');
+ player(game, owner).hand.push(source.splice(index, 1)[0]);
+}
+
+function declared(game: Game, owner = game.players[0].id) {
+ const card = player(game, owner).hand.find(candidate => candidate.effect === 'truthtrance');
+ assert.ok(card);
+ game = act(game, owner, { type: 'card', card: card.id });
+ for (let step = 0; game.truthtrance?.stage === 'priority' && step < 20; step++) {
+ const voter = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id))!;
+ game = act(game, voter.id, { type: 'truthPass' });
+ }
+ assert.equal(game.truthtrance?.stage, 'ask');
+ return game;
+}
+
+function ask(game: Game, target: string, question: TruthFact, owner = game.players[0].id) {
+ return act(declared(game, owner), owner, { type: 'truthAsk', question: { kind: 'fact', target, fact: question } });
+}
+
+function answer(game: Game, target: string, expected: 'yes' | 'no') {
+ const before = knowledgePhysical(game);
+ const question = structuredClone(game.truthtrance!.question);
+ assert.equal(viewGame(game, target).truthAnswer, expected);
+ for (const other of game.players.filter(candidate => candidate.id !== target)) {
+ assert.equal(viewGame(game, other.id).truthAnswer, null);
+ assert.equal(viewGame(game, other.id).players.find(candidate => candidate.id === target)!.hand, undefined);
+ }
+ reject(game, target, { type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes' });
+ reject(game, target, { type: 'truthAnswer', answer: 'unknown' });
+ game = act(game, target, { type: 'truthAnswer', answer: expected });
+ assert.equal(game.truthtrance, null);
+ assert.deepEqual(game.truthHistory!.at(-1)!.question, question);
+ assert.equal(game.truthHistory!.at(-1)!.answer, expected);
+ assert.deepEqual(knowledgePhysical(game), before);
+ assert.deepEqual(reload(normalizeAutomaticGame(reload(game))), game);
+ return game;
+}
+
+/** Genuine Advanced setup, followed by explicitly conserved typed positions. */
+function typedGame() {
+ const game = knowledgeGame(true);
+ const fremen = player(game, 'f');
+ Object.assign(fremen, { reserves: 10, tanks: 3, forces: { 'wind_pass:14': 7 },
+ elites: { reserves: 1, tanks: 1, forces: { 'wind_pass:14': 1 }, revived: 0 } });
+ assert.equal(knowledgePhysical(game).forces.find(candidate => candidate.id === 'f')!.total, 20);
+ return game;
+}
+
+void test('Truthtrance counts physical total, normal and elite forces in reserves, Tanks and exact board sectors', () => {
+ const initial = typedGame();
+ const rows: [ForceFact['zone'], number, number][] = [
+ [{ kind: 'reserves' }, 10, 1],
+ [{ kind: 'tanks' }, 3, 1],
+ [{ kind: 'location', territory: 'wind_pass', sector: 14 }, 7, 1],
+ [{ kind: 'location', territory: 'wind_pass', sector: 15 }, 0, 0],
+ ];
+ for (const [zone, total, elite] of rows)
+ for (const [counter, count] of [['total', total], ['normal', total - elite], ['elite', elite]] as const) {
+ answer(ask(initial, 'f', fact(zone, counter, count)), 'f', 'yes');
+ answer(ask(initial, 'f', fact(zone, counter, count + 1, 'gte')), 'f', 'no');
+ answer(ask(initial, 'f', fact(zone, counter, Number.MAX_SAFE_INTEGER, 'lte')), 'f', 'yes');
+ }
+});
+
+void test('Basic untracked starred factions allow total counts while ordinary absent elite tracking means zero elites', () => {
+ const initial = knowledgeGame(false);
+ assert.equal(player(initial, 'f').elites, undefined);
+ answer(ask(initial, 'f', fact({ kind: 'reserves' }, 'total', player(initial, 'f').reserves)), 'f', 'yes');
+ const waiting = declared(initial);
+ for (const counter of ['normal', 'elite'] as const)
+ reject(waiting, 'a', { type: 'truthAsk', question: { kind: 'fact', target: 'f', fact: fact({ kind: 'tanks' }, counter, 0) } });
+ assert.equal(player(initial, 'b').elites, undefined);
+ answer(ask(initial, 'b', fact({ kind: 'reserves' }, 'normal', player(initial, 'b').reserves)), 'b', 'yes');
+ answer(ask(initial, 'b', fact({ kind: 'tanks' }, 'elite', 0)), 'b', 'yes');
+ const ix = completedIxSkillsGame({ requestedSkill: 'warmaster' });
+ holdTruth(ix, 't');
+ assert.equal(player(ix, 'e').elites, undefined);
+ answer(ask(ix, 'e', fact({ kind: 'reserves' }, 'total', player(ix, 'e').reserves), 't'), 'e', 'yes');
+ const emperorWaiting = declared(ix, 't');
+ for (const counter of ['normal', 'elite'] as const)
+ reject(emperorWaiting, 't', { type: 'truthAsk', question: { kind: 'fact', target: 'e', fact: fact({ kind: 'tanks' }, counter, 0) } });
+});
+
+void test('physical force facts include Bene Gesserit advisors without exposing a separate advisor counter', () => {
+ const initial = knowledgeGame(true);
+ Object.assign(player(initial, 'b'), { reserves: 16, tanks: 0, forces: { 'imperial_basin:10': 4 },
+ advisors: { 'imperial_basin:10': {} } });
+ const zone: ForceFact['zone'] = { kind: 'location', territory: 'imperial_basin', sector: 10 };
+ answer(ask(initial, 'b', fact(zone, 'total', 4)), 'b', 'yes');
+ answer(ask(initial, 'b', fact(zone, 'normal', 4)), 'b', 'yes');
+ const waiting = declared(initial);
+ reject(waiting, 'a', { type: 'truthAsk', question: { kind: 'fact', target: 'b',
+ fact: { ...fact(zone, 'total', 4), counter: 'advisors' } } });
+});
+
+void test('a force fact combined with a private hand fact projects only its aggregate answer to the respondent', () => {
+ const initial = typedGame();
+ const target = player(initial, 'f');
+ const privateName = target.hand[0].name;
+ const combined: TruthFact = { kind: 'or', terms: [
+ fact({ kind: 'reserves' }, 'total', 11), { kind: 'hand', name: privateName },
+ ] };
+ const game = ask(initial, 'f', combined);
+ for (const id of ['a', 'b', 'o']) {
+ const view = viewGame(game, id);
+ assert.equal(view.truthAnswer, null);
+ assert.equal(view.players.find(candidate => candidate.id === 'f')!.hand, undefined);
+ assert.deepEqual(view.truthHistory, []);
+ }
+ const done = answer(game, 'f', 'yes');
+ assert.equal(done.truthHistory!.length, 1);
+ assert.deepEqual(done.truthHistory![0].question, { kind: 'fact', target: 'f', fact: combined });
+});
+
+void test('invalid force questions reject atomically before consuming Truthtrance or fixing an answer', () => {
+ const waiting = declared(typedGame());
+ const base = fact({ kind: 'reserves' }, 'total', 0);
+ const invalid = [
+ { ...base, value: -1 }, { ...base, value: 0.5 }, { ...base, value: Number.MAX_SAFE_INTEGER + 1 },
+ { ...base, value: '2' }, { ...base, compare: 'gt' }, { ...base, counter: 'combatStrength' },
+ { ...base, zone: { kind: 'homeworld', homeworld: 'salusa_secundus' } },
+ { ...base, zone: { kind: 'location', territory: 'salusa_secundus', sector: 0 } },
+ { ...base, zone: { kind: 'location', territory: 'arrakeen', sector: 11 } },
+ { ...base, zone: { kind: 'location', territory: 'arrakeen', sector: '10' } },
+ { ...base, zone: { kind: 'location', territory: MOBILE_STRONGHOLD, sector: 0 } },
+ { ...base, zone: { kind: 'location', territory: 'cistern', sector: 0 } },
+ ];
+ for (const question of invalid)
+ reject(waiting, 'a', { type: 'truthAsk', question: { kind: 'fact', target: 'f', fact: question } });
+ assert.equal(waiting.truthtrance!.stage, 'ask');
+ assert.equal(waiting.truthtrance!.question, null);
+ assert.equal(waiting.discard.length, 0);
+});
+
+void test('all four bot profiles pass priority and answer force facts legally from their private aggregate answer', () => {
+ for (const difficulty of DIFFICULTIES) {
+ const initial = typedGame();
+ const card = player(initial, 'a').hand.find(candidate => candidate.effect === 'truthtrance')!;
+ let game = act(initial, 'a', { type: 'card', card: card.id });
+ const priorityView = viewGame(game, 'f');
+ priorityView.players.find(candidate => candidate.id === 'f')!.bot = difficulty;
+ const actions = botActions(priorityView);
+ assert.deepEqual(actions, [{ type: 'truthPass' }]);
+ game = act(game, 'f', actions[0]);
+ while (game.truthtrance?.stage === 'priority') {
+ const voter = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id))!;
+ game = act(game, voter.id, { type: 'truthPass' });
+ }
+ game = act(game, 'a', { type: 'truthAsk', question: { kind: 'fact', target: 'f', fact: fact({ kind: 'tanks' }, 'elite', 1) } });
+ const view = viewGame(game, 'f');
+ view.players.find(candidate => candidate.id === 'f')!.bot = difficulty;
+ const before = structuredClone(view);
+ const responses = botActions(view);
+ assert.deepEqual(view, before);
+ assert.deepEqual(responses, [{ type: 'truthAnswer', answer: 'yes' }]);
+ assert.deepEqual(botActions(viewGame(game, 'b')), []);
+ const done = act(game, 'f', responses[0]);
+ assert.equal(done.truthtrance, null);
+ assert.deepEqual(knowledgePhysical(done), knowledgePhysical(game));
+ }
+});
+
+void test('historical force answers do not freeze a later legal revival or movement', () => {
+ const revival = typedGame();
+ Object.assign(revival, { phase: 4, active: null, phaseOpening: null, response: null, decision: null, storm: 18 });
+ let game = answer(ask(revival, 'f', fact({ kind: 'tanks' }, 'total', 3)), 'f', 'yes');
+ const history = structuredClone(game.truthHistory);
+ game = act(game, 'f', { type: 'revive', amount: 2, elite: 1 });
+ assert.equal(player(game, 'f').tanks, 1);
+ assert.equal(player(game, 'f').reserves, 12);
+ assert.deepEqual(game.truthHistory, history);
+
+ const movement = knowledgeGame(false);
+ Object.assign(movement, { phase: 5, active: 'o', movementRemaining: ['o', 'b', 'f', 'a'], phaseOpening: null,
+ response: null, decision: null, storm: 18 });
+ Object.assign(player(movement, 'o'), { forces: { 'imperial_basin:10': 3 }, reserves: 17, tanks: 0 });
+ game = answer(ask(movement, 'o', fact({ kind: 'location', territory: 'imperial_basin', sector: 10 }, 'total', 3)), 'o', 'yes');
+ const movementHistory = structuredClone(game.truthHistory);
+ game = act(game, 'o', { type: 'move', forces: { 'imperial_basin:10': 2 }, territory: 'arrakeen', sector: 10 });
+ assert.equal(player(game, 'o').forces['imperial_basin:10'], 1);
+ assert.equal(player(game, 'o').forces['arrakeen:10'], 2);
+ assert.deepEqual(game.truthHistory, movementHistory);
+});
+
+void test('the live HMS interior is an exact physical location distinct from its external pointer and cyborg strength', () => {
+ const initial = completedIxSkillsGame({ requestedSkill: 'warmaster' });
+ holdTruth(initial, 'e');
+ assert.equal(initial.mobileStronghold!.location, 'polar_sink:0');
+ assert.equal(player(initial, 'i').forces[MOBILE_LOCATION], 6);
+ assert.equal(player(initial, 'i').elites!.forces[MOBILE_LOCATION], 3);
+ for (const [counter, value] of [['total', 6], ['normal', 3], ['elite', 3]] as const)
+ assertIxSkillsCustody(answer(ask(initial, 'i', fact({ kind: 'location', territory: MOBILE_STRONGHOLD, sector: 0 }, counter, value), 'e'), 'i', 'yes'));
+ answer(ask(initial, 'i', fact({ kind: 'location', territory: 'polar_sink', sector: 0 }, 'total', 0), 'e'), 'i', 'yes');
+ const waiting = declared(initial, 'e');
+ reject(waiting, 'e', { type: 'truthAsk', question: { kind: 'fact', target: 'i',
+ fact: fact({ kind: 'location', territory: MOBILE_STRONGHOLD, sector: 1 }, 'total', 6) } });
+});
+
+void test('a Discovery location becomes queryable only after its genuine reveal', () => {
+ let game = discoveryFixture();
+ const token = enterDiscoveryCollection(game, 'cistern');
+ holdTruth(game, 'g');
+ const hidden = declared(game, 'g');
+ reject(hidden, 'g', { type: 'truthAsk', question: { kind: 'fact', target: 'a',
+ fact: fact({ kind: 'location', territory: 'cistern', sector: 0 }, 'total', 0) } });
+ game = act(game, 'a', { type: 'discovery', token: token.id, reveal: false });
+ game = act(game, 'a', { type: 'discovery', token: token.id, reveal: true });
+ assert.equal(game.discoveries!.tokens.find(candidate => candidate.id === token.id)!.revealedTurn, game.turn);
+ answer(ask(game, 'a', fact({ kind: 'location', territory: 'cistern', sector: 0 }, 'total', 0), 'g'), 'a', 'yes');
+});
+
+void test('Homeworld reserve facts read the faction aggregate and do not add a separate Homeworld query', () => {
+ const initial = homeworldRevivalFixture({ advanced: true });
+ holdTruth(initial, 'f');
+ const emperor = player(initial, 'e');
+ const total = emperor.reserves;
+ const elite = emperor.elites!.reserves;
+ for (const [counter, value] of [['total', total], ['normal', total - elite], ['elite', elite]] as const)
+ revivalInventory(answer(ask(initial, 'e', fact({ kind: 'reserves' }, counter, value), 'f'), 'e', 'yes'));
+ const waiting = declared(initial, 'f');
+ reject(waiting, 'f', { type: 'truthAsk', question: { kind: 'fact', target: 'e',
+ fact: { kind: 'forceCount', zone: { kind: 'homeworld', homeworld: 'salusa-secundus' }, counter: 'total', compare: 'eq', value: total } } });
+});
+
+void test('malformed saved force facts reject actions, private views and automatic recovery without mutation', () => {
+ const game = ask(typedGame(), 'f', fact({ kind: 'reserves' }, 'normal', 9));
+ const original = game.truthtrance!.question;
+ assert.equal(original?.kind, 'fact');
+ const base = fact({ kind: 'reserves' }, 'normal', 9);
+ const invalid: unknown[] = [
+ { ...base, value: -1 }, { ...base, value: Number.MAX_SAFE_INTEGER + 1 },
+ { ...base, counter: 'unknown' }, { ...base, extra: 'discarded field' },
+ { ...base, zone: { kind: 'location', territory: 'arrakeen', sector: 11 } },
+ { ...base, zone: { kind: 'location', territory: MOBILE_STRONGHOLD, sector: 0 } },
+ { ...base, zone: { kind: 'location', territory: 'cistern', sector: 0 } },
+ { ...base, zone: { kind: 'reserves', territory: 'arrakeen' } },
+ { kind: 'and', terms: [base, { ...base, value: -1 }] },
+ ];
+ for (const invalidFact of invalid) {
+ const saved = reload(game);
+ saved.truthtrance!.question = { kind: 'fact', target: 'f', fact: invalidFact as TruthFact };
+ const before = structuredClone(saved);
+ reject(saved, 'f', { type: 'truthAnswer', answer: 'yes' });
+ assert.throws(() => viewGame(saved, 'f'));
+ assert.throws(() => normalizeAutomaticGame(saved));
+ assert.deepEqual(saved, before);
+ }
+ const untracked = reload(game);
+ delete player(untracked, 'f').elites;
+ const before = structuredClone(untracked);
+ reject(untracked, 'f', { type: 'truthAnswer', answer: 'yes' });
+ assert.throws(() => viewGame(untracked, 'f'));
+ assert.throws(() => normalizeAutomaticGame(untracked));
+ assert.deepEqual(untracked, before);
+ answer(game, 'f', 'yes');
+});
diff --git a/tests/truthtrance-force-recovery.test.ts b/tests/truthtrance-force-recovery.test.ts
new file mode 100644
index 00000000..859ae59b
--- /dev/null
+++ b/tests/truthtrance-force-recovery.test.ts
@@ -0,0 +1,90 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { DatabaseSync } from 'node:sqlite';
+import type { Action, Game } from '../game/engine';
+import { unitStore } from './fixture-nexus-room-store';
+import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
+
+const clock = { now: () => 29000, sleep: async () => {} };
+const rows = (db: DatabaseSync) => db.prepare('SELECT state,version FROM rooms').all();
+
+void test('authenticated force-and-hand fact restores privately and concurrent truthful answers retire one card once', async () => {
+ const f = unitStore();
+ try {
+ const made = await f.rooms.createRoom('Force question', 'atreides', false, []);
+ const code = made.view.code;
+ const joined = [await f.rooms.joinRoom(code, 'Force target', 'emperor'), await f.rooms.joinRoom(code, 'Observer', 'guild')];
+ const auths = await Promise.all([made.token!, ...joined.map(p => p.token!)].map(token => f.restart().authenticate(code, token)));
+ const act = async (index: number, action: Action) => {
+ const before = await f.restart().readRoom(code);
+ await f.restart().act(code, auths[index], before.version, action, clock);
+ return f.restart().readRoom(code);
+ };
+ for (let i = 0; i < auths.length; i++) await act(i, { type: 'ready' });
+ let game = await act(0, { type: 'start' });
+ for (let i = 0; i < auths.length; i++)
+ game = await act(i, { type: 'traitor', leader: game.players.find(p => p.id === auths[i].playerId)!.traitorChoices[0] });
+ assert.equal(game.status, 'playing');
+ const inventory = sampleInventory(game), seats = f.sqlite.prepare('SELECT * FROM seats').all();
+ const asker = auths[0].playerId, target = auths[1].playerId;
+ const own = (g: Game, id: string) => g.players.find(p => p.id === id)!;
+ // Conserved fixture positions and physical cards, only in disposable SQLite.
+ Object.assign(own(game, target), { reserves: 14, tanks: 2, forces: { 'carthag:11': 4 } });
+ const give = (id: string, name: string) => {
+ const groups = [game.deck, ...game.players.map(p => p.hand)];
+ const source = groups.find(cards => cards.some(card => card.name === name))!;
+ const card = source.splice(source.findIndex(card => card.name === name), 1)[0];
+ own(game, id).hand.push(card); return card.id;
+ };
+ const truth = give(asker, 'Truthtrance'); give(target, 'Shield');
+ verifySampleCustody(game, inventory);
+ const oldVersion = game.version; game.version++;
+ assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
+ .run(JSON.stringify(game), game.version, code, oldVersion).changes, 1);
+ const parent = { phase: game.phase, decision: game.decision, response: game.response, stormDials: game.stormDials };
+ game = await act(0, { type: 'card', card: truth });
+ while (game.truthtrance?.stage === 'priority') {
+ const index = auths.findIndex(auth => !game.truthtrance!.passed.includes(auth.playerId));
+ assert.ok(index >= 0); game = await act(index, { type: 'truthPass' });
+ }
+ game = await act(0, { type: 'truthAsk', question: { kind: 'fact', target, fact: {
+ kind: 'and', terms: [
+ { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'eq', value: 14 },
+ { kind: 'hand', name: 'Shield' },
+ ],
+ } } });
+ assert.equal((await f.restart().readSeatView(code, auths[1])).truthAnswer, 'yes');
+ for (const index of [0, 2]) {
+ const view = await f.restart().readSeatView(code, auths[index]);
+ assert.equal(view.truthAnswer, null);
+ assert.equal(view.players.find(p => p.id === target)!.hand, undefined);
+ }
+ const persisted = rows(f.sqlite), version = game.version;
+ for (const [index, answer] of [[0, 'yes'], [1, 'no'], [1, 'unknown']] as const) {
+ await assert.rejects(f.restart().act(code, auths[index], version, { type: 'truthAnswer', answer }, clock));
+ assert.deepEqual(rows(f.sqlite), persisted);
+ }
+ let arrivals = 0; let release!: () => void;
+ const barrier = new Promise(resolve => { release = resolve; });
+ f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
+ const results = await Promise.allSettled([
+ f.rooms.act(code, auths[1], version, { type: 'truthAnswer', answer: 'yes' }, clock),
+ f.restart().act(code, auths[1], version, { type: 'truthAnswer', answer: 'yes' }, clock),
+ ]);
+ delete f.hooks.beforeWrite;
+ assert.equal(arrivals, 2); assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
+ assert.equal(results.filter(r => r.status === 'rejected').length, 1);
+ game = await f.restart().readRoom(code);
+ assert.equal(game.version, version + 1); assert.equal(game.truthtrance, null);
+ assert.equal(game.truthHistory?.length, 1); assert.equal(game.truthHistory![0].answer, 'yes');
+ assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+ assert.deepEqual({ phase: game.phase, decision: game.decision, response: game.response, stormDials: game.stormDials }, parent);
+ assert.equal(own(game, target).reserves, 14); assert.equal(own(game, target).tanks, 2);
+ assert.deepEqual(own(game, target).forces, { 'carthag:11': 4 });
+ verifySampleCustody(game, inventory);
+ assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
+ const completed = rows(f.sqlite);
+ await assert.rejects(f.restart().act(code, auths[1], version, { type: 'truthAnswer', answer: 'yes' }, clock));
+ assert.deepEqual(rows(f.sqlite), completed);
+ } finally { f.sqlite.close(); }
+});