From 89589878fc9aedcf82d502e9849159c39d6befdf Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:41:29 +1100 Subject: [PATCH] Support board-wide Truthtrance force questions --- components/truthtrance-force-count.tsx | 4 +- docs/CURRENT_STATUS.md | 10 +++++ docs/TRUTHTRANCE_FORCE_FACTS.md | 18 +++++++++ game/card-presentation.ts | 4 +- game/reference.ts | 8 ++-- game/truthtrance-force-count.ts | 47 ++++++++++++++++------- game/truthtrance-knowledge.ts | 7 +++- game/truthtrance.ts | 6 ++- tests/richese-card-presentation.test.ts | 8 +++- tests/truthtrance-force-controls.test.tsx | 11 +++++- tests/truthtrance-force-count.test.ts | 20 ++++++++++ tests/truthtrance-force-engine.test.ts | 30 ++++++++++++++- 12 files changed, 144 insertions(+), 29 deletions(-) diff --git a/components/truthtrance-force-count.tsx b/components/truthtrance-force-count.tsx index aee080cc..7d596c77 100644 --- a/components/truthtrance-force-count.tsx +++ b/components/truthtrance-force-count.tsx @@ -34,10 +34,11 @@ export function ForceCountFields({ value, game, player, onChange }: { onChange({ ...value, zone: kind === 'location' ? { kind, territory: territories[0].id, sector: territories[0].sectors[0] } : kind === 'territory' ? { kind, territory: territories[0].id } - : { kind: kind as 'reserves' | 'tanks' } }); + : { kind: kind as 'reserves' | 'tanks' | 'board' } }); }}> + @@ -66,6 +67,7 @@ export function ForceCountFields({ value, game, player, onChange }: { 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 })} />
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.
+ {value.zone.kind === 'board' &&The board includes placed Mobile Stronghold and revealed Discovery interiors. Reserves, Tanks and Homeworlds do not count.
} {!!forceCountCounterError(player, 'elite') &&This table does not track this faction’s starred counters separately. Choose all physical force counters.
} {error &&{error}
} >; diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index fca2fb88..d6ec1d45 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Whole-board Truthtrance forces — 29 October. Prototyped / Partial.** +Players can ask a verified physical-force question across the current Arrakis +board. The room's current locations include a placed Mobile Stronghold and +revealed Discovery interiors; unplaced interiors and concealed markers do not +contribute. Human and bot answers use the same current board context. Thirty-six +related cases pass, including staged games and the corrected Sapho card text; +typecheck, lint and the production build pass. Full Truthtrance and mode +acceptance remain open. +[Scope](TRUTHTRANCE_FORCE_FACTS.md#whole-board-physical-force-questions--29-october-2026). + **Whole-territory Truthtrance forces — 29 October. Prototyped / Partial.** Players can ask verified current physical force-count questions across an entire territory as well as a single sector, reserves or Tanks. The answer counts diff --git a/docs/TRUTHTRANCE_FORCE_FACTS.md b/docs/TRUTHTRANCE_FORCE_FACTS.md index 8914fb5e..27dde5e5 100644 --- a/docs/TRUTHTRANCE_FORCE_FACTS.md +++ b/docs/TRUTHTRANCE_FORCE_FACTS.md @@ -1,5 +1,23 @@ # Truthtrance physical-force facts +## Whole-board physical force questions — 29 October 2026 + +The force-count control can also ask whether the target has exactly, at least +or at most a selected number of physical counters on the current Arrakis board. +The server totals its authoritative room-local board locations: ordinary +territory sectors and, when active, the Hidden Mobile Stronghold and revealed +Discovery interiors. An unplaced Mobile Stronghold is not yet on the board; +reserves, Tanks, Homeworld pools and concealed No-Field markers do not count. +Physical Bene Gesserit advisors do count. Special-counter splits and per-sector +validation match the existing selected-territory facts. + +The November 2020 GF9 FAQ p.8 permits game-related yes/no questions; this is +an explicit physical-counter interpretation, not a newly located specific +whole-board FAQ. It does not answer broader arbitrary questions. When a caller +has no room-local board context, the question remains unknown rather than +guessing a total. The normal human and bot response paths use the same private +answer, and the existing one-card disposal and continuation remain unchanged. + Development prototype, 21 September 2026. Structured questions now compare the respondent's current physical counters in reserves, the Tanks, a whole territory or one exact board location. This extends the existing fact family; it does not complete arbitrary diff --git a/game/card-presentation.ts b/game/card-presentation.ts index deb9422e..fb23fea0 100644 --- a/game/card-presentation.ts +++ b/game/card-presentation.ts @@ -36,7 +36,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null { if (definition?.card.effect === 'nullentropyBox') return 'Use the Nullentropy Box panel to pay for a private search.'; if (definition?.card.effect === 'juiceOfSapho') - return 'Use the Juice of Sapho panel to choose an available order change or become battle aggressor before plans.'; + return 'Use the Juice of Sapho panel to choose an available order change or become battle aggressor. Fresh Richese battles can offer the aggressor choice until both combatants finish their final battle actions; older battles retain their pre-plan opportunity.'; if (definition?.card.effect === 'distrans') return 'Choose a recipient and card in the Distrans transfer panel.'; if (definition?.card.effect === 'semutaDrug') @@ -90,7 +90,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation { gameplay: richese.gameplay, availability: richese.card.effect === 'juiceOfSapho' - ? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and aggressor during the shared pre-plan opportunity. Earlier bids stay committed. Later aggressor intervention and other phase or auction modes remain unfinished; supported Richese and expansion starts are available as unfinished previews.' + ? 'Richese effect integration and verification are incomplete. Supported controls cover Once Around first or last while your bid remains open, bounded movement and battle-choice ordering, and a shared aggressor opportunity. Fresh Richese battles can offer it until both combatants finish final battle actions; older battles retain their pre-plan opportunity. Earlier bids stay committed. Other timing and phase combinations remain unfinished; supported Richese and expansion starts are available as unfinished previews.' : richese.card.effect === 'semutaDrug' ? 'The explicit Semuta development preview offers neutral responses after clean public ordinary-card discards, paid Box disposal, retired Ornithopter movement (including a following Bene Gesserit Intrusion), Distrans, ordinary and queued definite Truthtrance answers, four Sapho scopes, unready pre-leader Residual Poison, printed Karama cancelling CHOAM Charity/Inflation/Bene Gesserit Charity, native capture/Auditor/battle-income/Ix-substitution/Basic allied-retention powers, or settling a normal-auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset, proactive/final-Mentat Trip to Gamont with an ordinary/elite force or concealed No-Field, accepted Jubba Cloak before storm protection, Ix-deck Thumper/Amal, three battle discard stages (including before original Tech award, native Advanced capture, Face Dance, quoted CHOAM income or Auditor) and private normal-auction or Black Market Ixian ally replacements. Acquiring a printed cancellation cost never restores its canceled effect. Tech requires a supported fresh 3–6-seat CHOAM/optional-Ix profile. Native Ixian casualty substitution keeps its original before-Tooth order. Native Basic Moritani cleanup offers only unkept played cards after the original choice and Karama response, preserving its kept card and unchanged original retention pool. The Basic Richese/Moritani CHOAM/Ecaz entry also adds private Sabotage and Robbery overflow after clean shipment/movement arrivals, preserving the original victim/draw and arrival suffix. Commitment is owner-only; the approved full-hand exchange spends Semuta without increasing your completed hand. Fresh bare Basic Richese previews now initialize these reactions with selected native E1/E2/E3 families. Clean native or BG-copy CHOAM and Ixian Ambassador private discards after ordinary shipment/movement retain the committed payout or pause before the replacement draw. Old games are unchanged; optional/Advanced entries retain their existing scope. Other pending rewards, producers and nested arrivals remain unfinished.' : richese.card.effect === 'mirrorWeapon' diff --git a/game/reference.ts b/game/reference.ts index ee2ef838..d8af56e6 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3027,9 +3027,9 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'Ask about current physical counters in reserves, the Tanks, an entire territory or an exact board location.', + summary: 'Ask about current physical counters in reserves, the Tanks, the board, an entire territory or an exact board location.', steps: [ - 'Choose Current physical forces, then the pool, counter type and an Exactly, At least or At most comparison. Ask about an entire territory or name an exact sector. Available HMS interiors and revealed Discovery locations are separate from their outside territories.', + 'Choose Current physical forces, then the pool, counter type and an Exactly, At least or At most comparison. Ask about the whole board, an entire territory or an exact sector. The board count includes a placed HMS and revealed Discovery interiors; they remain separate from their outside territories for territory and sector questions.', '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.', 'Use the separate Current concealed No-Field printed value question for a token denomination. Concealed5 can coexist with exactly zero physical counters at its sector and zero reserves; this still is not five materialized forces. Public Yes/No does not itself reveal or materialize the marker.', '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.', @@ -3037,8 +3037,8 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['card-truthtrance', 'force-counters', 'truthtrance-spice', 'richese-no-field', 'privacy'], checklist: [ - { area: 'Implementation', status: 'Implemented', detail: 'Physical pool comparisons, entire territories and 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, whole territory or exact sector, counter type and numeric comparison share validation with the server.', evidence: ['components/truthtrance-force-count.tsx', 'components/truthtrance.tsx'] }, + { area: 'Implementation', status: 'Implemented', detail: 'Physical pool comparisons, the live board, entire territories and exact 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, board, whole territory or exact 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'] }, diff --git a/game/truthtrance-force-count.ts b/game/truthtrance-force-count.ts index ae03ea03..050b07a5 100644 --- a/game/truthtrance-force-count.ts +++ b/game/truthtrance-force-count.ts @@ -1,4 +1,4 @@ -import { location, territory } from './board'; +import { location, territory, validLocation } from './board'; import type { FactionId } from './catalog'; export type ForceCountFact = { @@ -6,6 +6,7 @@ export type ForceCountFact = { zone: | { kind: 'reserves' } | { kind: 'tanks' } + | { kind: 'board' } | { kind: 'territory'; territory: string } | { kind: 'location'; territory: string; sector: number }; counter: 'total' | 'normal' | 'elite'; @@ -80,9 +81,9 @@ export function parseForceCountFact( 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 (zone.kind === 'reserves' || zone.kind === 'tanks' || zone.kind === 'board') { if (!exactKeys(zone, ['kind'])) - throw new Error('Reserve and Tanks facts cannot name a board location.'); + throw new Error('Reserve, Tanks and board facts cannot name a location.'); parsedZone = { kind: zone.kind }; } else if (zone.kind === 'territory') { if ( @@ -110,7 +111,7 @@ export function parseForceCountFact( sector: zone.sector as number, }; } else { - throw new Error('Choose reserves, the Tanks, a current territory, or a board location.'); + throw new Error('Choose reserves, the Tanks, the board, a current territory, or a board location.'); } return { kind: 'forceCount', @@ -133,7 +134,7 @@ export function forceCountCounterError( : null; } -function chosenPool(player: ForceCountPlayer, fact: ForceCountFact) { +function chosenPool(player: ForceCountPlayer, fact: ForceCountFact, boardLocations?: readonly ValidLocation[]) { const eliteState = player.elites; if (eliteState !== undefined && !record(eliteState)) throw new Error('The tracked special-force split is malformed.'); @@ -151,12 +152,27 @@ function chosenPool(player: ForceCountPlayer, fact: ForceCountFact) { throw new Error('The selected board force pool is malformed.'); if (eliteState !== undefined && !record(eliteState.forces)) throw new Error('The selected elite board force pool is malformed.'); - const sectors = fact.zone.kind === 'territory' - ? territory(fact.zone.territory).sectors - : [fact.zone.sector]; + const zone = fact.zone; + const selected = zone.kind === 'board' + ? boardLocations + : zone.kind === 'territory' + ? territory(zone.territory).sectors.map(sector => ({ territory: zone.territory, sector })) + : [{ territory: zone.territory, sector: zone.sector }]; + if (!selected || !selected.length) throw new Error('Current board locations are unavailable.'); + if (zone.kind === 'board') { + const seen = new Set