Support board-wide Truthtrance force questions
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 03:41:29 +11:00
1 parent f1007a4ea8
commit 89589878fc
12 files changed
+144 -29

No files matched your search

+2 -2
View File
@@ -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'
+4 -4
View File
@@ -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'] },
+33 -14
View File
@@ -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<string>();
for (const entry of selected) {
if (!entry || typeof entry.territory !== 'string' || !Number.isSafeInteger(entry.sector) ||
!validLocation(entry.territory, entry.sector))
throw new Error('Current board locations are malformed.');
const key = location(entry.territory, entry.sector);
if (seen.has(key)) throw new Error('Current board locations contain a duplicate.');
seen.add(key);
}
}
let total = 0, elite = 0;
for (const sector of sectors) {
const key = location(fact.zone.territory, sector);
for (const selectedLocation of selected) {
const key = location(selectedLocation.territory, selectedLocation.sector);
const sectorTotal = count(player.forces[key] === undefined ? 0 : player.forces[key], 'The selected physical force count');
const storedElite = eliteState?.forces[key];
const sectorElite = count(storedElite === undefined ? 0 : storedElite, 'The selected elite force count');
@@ -181,10 +197,11 @@ function chosenPool(player: ForceCountPlayer, fact: ForceCountFact) {
export function forceCountFactMatches(
player: ForceCountPlayer,
fact: ForceCountFact,
boardLocations?: readonly ValidLocation[],
): boolean {
const unavailable = forceCountCounterError(player, fact.counter);
if (unavailable) throw new Error(unavailable);
const pool = chosenPool(player, fact);
const pool = chosenPool(player, fact, boardLocations);
const actual =
fact.counter === 'total'
? pool.total
@@ -217,8 +234,10 @@ export function forceCountFactText(fact: ForceCountFact): string {
? 'in your reserves'
: fact.zone.kind === 'tanks'
? 'in the Tleilaxu Tanks'
: fact.zone.kind === 'territory'
? `in ${territory(fact.zone.territory).name} across all sectors`
: `in ${territory(fact.zone.territory).name}, sector ${fact.zone.sector}`;
: fact.zone.kind === 'board'
? 'on the current Arrakis board'
: fact.zone.kind === 'territory'
? `in ${territory(fact.zone.territory).name} across all sectors`
: `in ${territory(fact.zone.territory).name}, sector ${fact.zone.sector}`;
return `you currently have ${comparison} ${fact.value} ${counters} ${zone}`;
}
+5 -2
View File
@@ -1,6 +1,6 @@
import { FACTIONS, type FactionId } from './catalog';
import { leaders, SPICE_CARDS } from './cards';
import { territory } from './board';
import { gameTerritories, territory } from './board';
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
import { battleCategoryInspectionValue } from './battle-card-slots';
import type { Game, Player } from './engine';
@@ -31,6 +31,8 @@ export type KnowledgeFact =
/** Only values already chosen or actually known by this respondent. */
export type TruthKnowledge = {
/** Public board locations available in this room at the time of the answer. */
boardLocations?: readonly { territory: string; sector: number }[];
prediction: { faction: FactionId; turn: number } | null;
stormDial: number | null;
stormForecast: number | null;
@@ -96,7 +98,7 @@ export function parseKnowledgeFact(value: unknown): KnowledgeFact {
}
export function truthKnowledgeOf(
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | 'richeseCache' | 'richesePeekKnown' | 'richeseOfferedCard' | 'ixAuction' | 'ixAuctionKnown' | 'homeworldOccupationPreview' | 'battle' | 'players' | 'dukeVidal'>,
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | 'richeseCache' | 'richesePeekKnown' | 'richeseOfferedCard' | 'ixAuction' | 'ixAuctionKnown' | 'homeworldOccupationPreview' | 'battle' | 'players' | 'dukeVidal' | 'mobileStronghold' | 'discoveries'>,
p: Pick<Player, 'id' | 'faction' | 'prediction'>,
): TruthKnowledge {
const dial = g.stormDials[p.id];
@@ -157,6 +159,7 @@ export function truthKnowledgeOf(
}
}
return {
boardLocations: gameTerritories(g).flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))),
prediction: p.faction === 'beneGesserit' && p.prediction
? { ...p.prediction } : null,
stormDial: g.phase === 0 && g.stormDialers.includes(p.id) &&
+4 -2
View File
@@ -741,8 +741,10 @@ export function truthFactAnswer(
if (isKnowledgeFact(fact)) return knowledgeFactAnswer(fact, knowledge);
if (fact.kind === 'noFieldValue')
return noFieldValueFactMatches(p.noField, fact) ? 'yes' : 'no';
if (fact.kind === 'forceCount')
return forceCountFactMatches(p as ForceCountPlayer, fact) ? 'yes' : 'no';
if (fact.kind === 'forceCount') {
if (fact.zone.kind === 'board' && !knowledge?.boardLocations) return 'unknown';
return forceCountFactMatches(p as ForceCountPlayer, fact, knowledge?.boardLocations) ? 'yes' : 'no';
}
if (fact.kind === 'handInventory')
return handInventoryFactMatches(p.hand, fact) ? 'yes' : 'no';
if (fact.kind === 'handCount')