Add current physical-force questions to Truthtrance

Let players compare normal, elite or total counters in reserves, the Tanks
or one live board territory and sector. Reuse compound facts and private
answers. Exclude concealed No-Field values and combat-strength modifiers;
guard Basic factions without a tracked starred-counter split.

Reparse saved fact questions before actions and projection, including
nested leaves and dynamic locations. Preserve interrupted decisions,
physical card disposal and historical answers after later legal movement.
Connect controls, contextual guidance and the capability checklist while
leaving full mode, combination and publication gates unchanged.

Validation: types, lint, 5122 offline tests and production build passed.
Independent review and 21 focused pure, engine, control and authenticated
concurrent recovery tests passed. Six complete-game samples finished with
1551 legal actions, 39 JSON restores and no rejected actions.
These are continuation checks, not AI strength calibration.

Browser room 86WE6LXV exercised genuine setup, compound physical counts,
AI response, mobile controls, human private-answer refresh and publication.
Two existing Truthtrance Cards retired once each and forces stayed intact.
All 1,584 prior rooms, 3,352 seats and auxiliary records remain unchanged;
one QA room and human seat were added. No server restart or database reset.
Existing screenshot capture failure leaves full visual acceptance open.
This commit is contained in:
Quintine committed 2026-09-21 15:39:39 +10:00
1 parent d1bf33d0bc
commit 7136c8dd19
14 files changed
+1112 -17

No files matched your search

+4
View File
@@ -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).
+66
View File
@@ -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<ForceCountPlayer, 'faction' | 'elites'>;
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 <>
<label>Force pool<select value={value.zone.kind} onChange={event => {
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' } });
}}>
<option value="reserves">Reserves</option>
<option value="tanks">Tleilaxu Tanks</option>
<option value="location">One board location</option>
</select></label>
{value.zone.kind === 'location' && <>
<label>Force territory<select value={value.zone.territory} onChange={event => {
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 => <option key={t.id} value={t.id}>{t.name}</option>)}</select></label>
<label>Force sector<select value={value.zone.sector} onChange={event => {
if (value.zone.kind === 'location') onChange({ ...value, zone: { ...value.zone, sector: Number(event.target.value) } });
}}>{selected?.sectors.map(sector => <option key={sector} value={sector}>{sector === 0 ? 'Interior / whole location' : `Sector ${sector}`}</option>)}</select></label>
</>}
<label>Counter type<select value={value.counter} onChange={event => onChange({ ...value, counter: event.target.value as ForceCountFact['counter'] })}>
<option value="total">All physical force counters</option>
<option value="normal" disabled={!!forceCountCounterError(player, 'normal')}>Normal counters</option>
<option value="elite" disabled={!!forceCountCounterError(player, 'elite')}>Elite counters</option>
</select></label>
<label>Compare force count<select value={value.compare} onChange={event => onChange({ ...value, compare: event.target.value as ForceCountFact['compare'] })}>
<option value="eq">Exactly</option><option value="gte">At least</option><option value="lte">At most</option>
</select></label>
<label htmlFor={`${id}-count`}>Number of force counters<Input id={`${id}-count`} type="number" min={0} max={Number.MAX_SAFE_INTEGER} step={1} required
value={Number.isNaN(value.value) ? '' : value.value} aria-invalid={!!error} aria-describedby={`${id}-help${error ? ` ${id}-error` : ''}`}
onChange={event => onChange({ ...value, value: event.currentTarget.valueAsNumber })} /></label>
<p id={`${id}-help`} className="fine">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.</p>
{!!forceCountCounterError(player, 'elite') && <p className="fine">This table does not track this faction’s starred counters separately. Choose all physical force counters.</p>}
{error && <p id={`${id}-error`} className="notice" role="alert">{error}</p>}
</>;
}
+10 -2
View File
@@ -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({
<option value="spice">
Current personal spice
</option>
<option value="forceCount">Current physical forces</option>
</select>
</label>
{c.kind === 'hand' ? (
@@ -506,6 +511,9 @@ export function Truthtrance({
)
}
/>
) : c.kind === 'forceCount' ? (
<ForceCountFields value={c} game={g} player={g.players.find(p => p.id === target)!}
onChange={next => setClauses(clauses.map((old, n) => n === index ? next : old))} />
) : c.kind === 'prediction' || c.kind === 'stormDial' || c.kind === 'stormForecast' ? (
<KnowledgeFactFields
value={c}
@@ -622,7 +630,7 @@ export function Truthtrance({
))}
<p className="notice">
{invalidFact
? 'Enter a valid whole amount before asking this question.'
? 'Resolve the marked fields before asking this question.'
: truthQuestionText(question, leaderName)}
</p>
</>
+12 -11
View File
@@ -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
+9
View File
@@ -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
+7
View File
@@ -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.
+75
View File
@@ -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.
+24
View File
@@ -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',
+202
View File
@@ -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<Record<string, number>>;
elites?: Readonly<{
reserves: number;
tanks: number;
forces: Readonly<Record<string, number>>;
}>;
};
type ValidLocation = Readonly<{ territory: string; sector: number }>;
const splitFactions = new Set<FactionId>(['emperor', 'fremen', 'ixians']);
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function exactKeys(
value: Record<string, unknown>,
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<ForceCountPlayer, 'faction' | 'elites'>,
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}`;
}
+28 -4
View File
@@ -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<string, unknown> => {
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<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice'>,
p: Pick<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice'> & Partial<ForceCountPlayer>,
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}`;
+55
View File
@@ -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<ForceCountPlayer, 'faction' | 'elites'> = { 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);
});
+242
View File
@@ -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',
);
});
+288
View File
@@ -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<TruthFact, { kind: 'forceCount' }>;
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');
});
+90
View File
@@ -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<void>(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(); }
});