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 <> + + {value.zone.kind === 'location' && <> + + + } + + + +

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 && } + ; +} 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({ + {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(); } +});