Add current physical-force questions to Truthtrance
Let players compare normal, elite or total counters in reserves, the Tanks or one live board territory and sector. Reuse compound facts and private answers. Exclude concealed No-Field values and combat-strength modifiers; guard Basic factions without a tracked starred-counter split. Reparse saved fact questions before actions and projection, including nested leaves and dynamic locations. Preserve interrupted decisions, physical card disposal and historical answers after later legal movement. Connect controls, contextual guidance and the capability checklist while leaving full mode, combination and publication gates unchanged. Validation: types, lint, 5122 offline tests and production build passed. Independent review and 21 focused pure, engine, control and authenticated concurrent recovery tests passed. Six complete-game samples finished with 1551 legal actions, 39 JSON restores and no rejected actions. These are continuation checks, not AI strength calibration. Browser room 86WE6LXV exercised genuine setup, compound physical counts, AI response, mobile controls, human private-answer refresh and publication. Two existing Truthtrance Cards retired once each and forces stayed intact. All 1,584 prior rooms, 3,352 seats and auxiliary records remain unchanged; one QA room and human seat were added. No server restart or database reset. Existing screenshot capture failure leaves full visual acceptance open.
This commit is contained in:
1 parent
d1bf33d0bc
commit
7136c8dd19
14 files changed
+1112
-17
No files matched your search
@@ -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',
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { location, territory } from './board';
|
||||
import type { FactionId } from './catalog';
|
||||
|
||||
export type ForceCountFact = {
|
||||
kind: 'forceCount';
|
||||
zone:
|
||||
| { kind: 'reserves' }
|
||||
| { kind: 'tanks' }
|
||||
| { kind: 'location'; territory: string; sector: number };
|
||||
counter: 'total' | 'normal' | 'elite';
|
||||
compare: 'eq' | 'gte' | 'lte';
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type ForceCountPlayer = {
|
||||
faction: FactionId;
|
||||
reserves: number;
|
||||
tanks: number;
|
||||
forces: Readonly<Record<string, number>>;
|
||||
elites?: Readonly<{
|
||||
reserves: number;
|
||||
tanks: number;
|
||||
forces: Readonly<Record<string, number>>;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ValidLocation = Readonly<{ territory: string; sector: number }>;
|
||||
|
||||
const splitFactions = new Set<FactionId>(['emperor', 'fremen', 'ixians']);
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
) {
|
||||
const actual = Object.keys(value).sort();
|
||||
const wanted = [...expected].sort();
|
||||
return (
|
||||
actual.length === wanted.length &&
|
||||
actual.every((key, index) => key === wanted[index])
|
||||
);
|
||||
}
|
||||
|
||||
function count(value: unknown, label: string) {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
||||
throw new Error(`${label} must be a nonnegative safe whole number.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Parse a current public force fact against the room's actual board locations. */
|
||||
export function parseForceCountFact(
|
||||
value: unknown,
|
||||
validLocations: readonly ValidLocation[],
|
||||
): ForceCountFact {
|
||||
if (
|
||||
!record(value) ||
|
||||
!exactKeys(value, ['kind', 'zone', 'counter', 'compare', 'value']) ||
|
||||
value.kind !== 'forceCount'
|
||||
)
|
||||
throw new Error('Choose only a current physical force-count fact.');
|
||||
if (
|
||||
value.counter !== 'total' &&
|
||||
value.counter !== 'normal' &&
|
||||
value.counter !== 'elite'
|
||||
)
|
||||
throw new Error('Choose total, normal, or elite physical forces.');
|
||||
if (
|
||||
value.compare !== 'eq' &&
|
||||
value.compare !== 'gte' &&
|
||||
value.compare !== 'lte'
|
||||
)
|
||||
throw new Error(
|
||||
'Compare force counts using exactly, at least, or at most.',
|
||||
);
|
||||
const threshold = count(value.value, 'The force threshold');
|
||||
const zone = value.zone;
|
||||
if (!record(zone)) throw new Error('Choose a current force pool.');
|
||||
let parsedZone: ForceCountFact['zone'];
|
||||
if (zone.kind === 'reserves' || zone.kind === 'tanks') {
|
||||
if (!exactKeys(zone, ['kind']))
|
||||
throw new Error('Reserve and Tanks facts cannot name a board location.');
|
||||
parsedZone = { kind: zone.kind };
|
||||
} else if (zone.kind === 'location') {
|
||||
if (
|
||||
!exactKeys(zone, ['kind', 'territory', 'sector']) ||
|
||||
typeof zone.territory !== 'string' ||
|
||||
!Number.isSafeInteger(zone.sector) ||
|
||||
!validLocations.some(
|
||||
(candidate) =>
|
||||
candidate.territory === zone.territory &&
|
||||
candidate.sector === zone.sector,
|
||||
)
|
||||
)
|
||||
throw new Error('Choose an exact current board territory and sector.');
|
||||
parsedZone = {
|
||||
kind: 'location',
|
||||
territory: zone.territory,
|
||||
sector: zone.sector as number,
|
||||
};
|
||||
} else {
|
||||
throw new Error('Choose reserves, the Tanks, or a current board location.');
|
||||
}
|
||||
return {
|
||||
kind: 'forceCount',
|
||||
zone: parsedZone,
|
||||
counter: value.counter,
|
||||
compare: value.compare,
|
||||
value: threshold,
|
||||
};
|
||||
}
|
||||
|
||||
/** Public reason that denomination-specific facts cannot be answered safely. */
|
||||
export function forceCountCounterError(
|
||||
player: Pick<ForceCountPlayer, 'faction' | 'elites'>,
|
||||
counter: ForceCountFact['counter'],
|
||||
): string | null {
|
||||
return counter !== 'total' &&
|
||||
splitFactions.has(player.faction) &&
|
||||
player.elites === undefined
|
||||
? 'Normal and elite force counts are unavailable without a tracked special-force split.'
|
||||
: null;
|
||||
}
|
||||
|
||||
function chosenPool(player: ForceCountPlayer, fact: ForceCountFact) {
|
||||
const eliteState = player.elites;
|
||||
if (eliteState !== undefined && !record(eliteState))
|
||||
throw new Error('The tracked special-force split is malformed.');
|
||||
|
||||
let totalValue: unknown;
|
||||
let eliteValue: unknown = 0;
|
||||
if (fact.zone.kind === 'reserves') {
|
||||
totalValue = player.reserves;
|
||||
if (eliteState !== undefined) eliteValue = eliteState.reserves;
|
||||
} else if (fact.zone.kind === 'tanks') {
|
||||
totalValue = player.tanks;
|
||||
if (eliteState !== undefined) eliteValue = eliteState.tanks;
|
||||
} else {
|
||||
if (!record(player.forces))
|
||||
throw new Error('The selected board force pool is malformed.');
|
||||
const key = location(fact.zone.territory, fact.zone.sector);
|
||||
totalValue = player.forces[key] ?? 0;
|
||||
if (eliteState !== undefined) {
|
||||
if (!record(eliteState.forces))
|
||||
throw new Error('The selected elite board force pool is malformed.');
|
||||
eliteValue = eliteState.forces[key] ?? 0;
|
||||
}
|
||||
}
|
||||
const total = count(totalValue, 'The selected physical force count');
|
||||
const elite = count(eliteValue, 'The selected elite force count');
|
||||
if (elite > total)
|
||||
throw new Error(
|
||||
'The selected elite force count exceeds its physical force pool.',
|
||||
);
|
||||
return { total, elite };
|
||||
}
|
||||
|
||||
/** Compare one current physical pool; unrelated pools are deliberately unread. */
|
||||
export function forceCountFactMatches(
|
||||
player: ForceCountPlayer,
|
||||
fact: ForceCountFact,
|
||||
): boolean {
|
||||
const unavailable = forceCountCounterError(player, fact.counter);
|
||||
if (unavailable) throw new Error(unavailable);
|
||||
const pool = chosenPool(player, fact);
|
||||
const actual =
|
||||
fact.counter === 'total'
|
||||
? pool.total
|
||||
: fact.counter === 'elite'
|
||||
? pool.elite
|
||||
: pool.total - pool.elite;
|
||||
return fact.compare === 'eq'
|
||||
? actual === fact.value
|
||||
: fact.compare === 'gte'
|
||||
? actual >= fact.value
|
||||
: actual <= fact.value;
|
||||
}
|
||||
|
||||
export function forceCountFactText(fact: ForceCountFact): string {
|
||||
const comparison =
|
||||
fact.compare === 'eq'
|
||||
? 'exactly'
|
||||
: fact.compare === 'gte'
|
||||
? 'at least'
|
||||
: 'at most';
|
||||
const plural = fact.value === 1 ? 'force' : 'forces';
|
||||
const counters =
|
||||
fact.counter === 'total'
|
||||
? `physical ${plural}`
|
||||
: fact.counter === 'normal'
|
||||
? `normal ${plural}`
|
||||
: `elite ${plural}`;
|
||||
const zone =
|
||||
fact.zone.kind === 'reserves'
|
||||
? 'in your reserves'
|
||||
: fact.zone.kind === 'tanks'
|
||||
? 'in the Tleilaxu Tanks'
|
||||
: `in ${territory(fact.zone.territory).name}, sector ${fact.zone.sector}`;
|
||||
return `you currently have ${comparison} ${fact.value} ${counters} ${zone}`;
|
||||
}
|
||||
+28
-4
@@ -24,7 +24,8 @@ import {
|
||||
planClaimText,
|
||||
type PlanClaim,
|
||||
} from './battle-promises';
|
||||
import { territory } from './board';
|
||||
import { gameTerritories, territory } from './board';
|
||||
import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCountCounterError, type ForceCountFact, type ForceCountPlayer } from './truthtrance-force-count';
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { treacheryDeck } from './cards';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
@@ -36,6 +37,7 @@ export type TruthFact =
|
||||
| KnowledgeFact
|
||||
| CardCountFact
|
||||
| HandInventoryFact
|
||||
| ForceCountFact
|
||||
| { kind: 'hand'; name: string }
|
||||
| { kind: 'traitor'; leader: string }
|
||||
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
|
||||
@@ -87,6 +89,7 @@ const record = (value: unknown): Record<string, unknown> => {
|
||||
function parseFact(
|
||||
g: Game,
|
||||
value: unknown,
|
||||
respondent: Player,
|
||||
depth = 0,
|
||||
budget = { remaining: 16 },
|
||||
): TruthFact {
|
||||
@@ -95,6 +98,17 @@ function parseFact(
|
||||
'Truthtrance supports at most 16 clauses and four levels of grouping.',
|
||||
);
|
||||
const v = record(value);
|
||||
if (v.kind === 'forceCount') {
|
||||
try {
|
||||
const fact = parseForceCountFact(v, gameTerritories(g).flatMap(t =>
|
||||
t.sectors.map(sector => ({ territory: t.id, sector }))));
|
||||
const unavailable = forceCountCounterError(respondent, fact.counter);
|
||||
check(!unavailable, unavailable ?? 'Choose a recorded physical counter type.');
|
||||
return fact;
|
||||
} catch (error) {
|
||||
throw new TruthError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
if (isKnowledgeFact(v)) {
|
||||
try { return parseKnowledgeFact(v); }
|
||||
catch (error) { throw new TruthError(error instanceof Error ? error.message : String(error)); }
|
||||
@@ -148,7 +162,7 @@ function parseFact(
|
||||
);
|
||||
return {
|
||||
kind: v.kind,
|
||||
terms: v.terms.map((term) => parseFact(g, term, depth + 1, budget)),
|
||||
terms: v.terms.map((term) => parseFact(g, term, respondent, depth + 1, budget)),
|
||||
};
|
||||
}
|
||||
/** Shared battle discs are valid plan identities, but never expand the traitor inventory. */
|
||||
@@ -208,7 +222,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
};
|
||||
}
|
||||
if (v.kind === 'fact')
|
||||
return { kind: 'fact', target: v.target, fact: parseFact(g, v.fact) };
|
||||
return { kind: 'fact', target: v.target, fact: parseFact(g, v.fact, g.players.find(p => p.id === v.target)!) };
|
||||
check(
|
||||
v.kind === 'freeform' &&
|
||||
typeof v.text === 'string' &&
|
||||
@@ -267,6 +281,13 @@ export function validateSavedTruthtrance(g: Game) {
|
||||
'The saved Truthtrance must retain each committed physical card in its holder’s hand.',
|
||||
);
|
||||
}
|
||||
if (window?.question?.kind === 'fact') {
|
||||
check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
|
||||
'The saved fact question must retain its asking player and answer stage.');
|
||||
check(JSON.stringify(parseQuestion(g, window.queue[0].player, window.question)) === JSON.stringify(window.question),
|
||||
'The saved fact question no longer matches its recorded form.');
|
||||
return;
|
||||
}
|
||||
if (window?.question?.kind !== 'shipment') return;
|
||||
check(
|
||||
(window.stage === 'answer' || window.stage === 'unknown') &&
|
||||
@@ -303,11 +324,13 @@ export function queuedTruthCardMatches(
|
||||
}
|
||||
/** A fact answer never returns which disjunct matched or any underlying private cards. */
|
||||
export function truthFactAnswer(
|
||||
p: Pick<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice'>,
|
||||
p: Pick<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice'> & Partial<ForceCountPlayer>,
|
||||
fact: TruthFact,
|
||||
knowledge?: TruthKnowledge,
|
||||
): TruthAnswer {
|
||||
if (isKnowledgeFact(fact)) return knowledgeFactAnswer(fact, knowledge);
|
||||
if (fact.kind === 'forceCount')
|
||||
return forceCountFactMatches(p as ForceCountPlayer, fact) ? 'yes' : 'no';
|
||||
if (fact.kind === 'handInventory')
|
||||
return handInventoryFactMatches(p.hand, fact) ? 'yes' : 'no';
|
||||
if (fact.kind === 'handCount')
|
||||
@@ -352,6 +375,7 @@ export function truthQuestionText(
|
||||
return `In this battle in ${territory(q.territory).name}, will it be true that ${planClaimText(q.claim, leaderName)}?`;
|
||||
const clause = (f: TruthFact): string => {
|
||||
if (isKnowledgeFact(f)) return knowledgeFactText(f);
|
||||
if (f.kind === 'forceCount') return forceCountFactText(f);
|
||||
if (f.kind === 'handInventory') return handInventoryFactText(f);
|
||||
if (f.kind === 'handCount') return cardCountFactText(f);
|
||||
if (f.kind === 'hand') return `you hold ${f.name}`;
|
||||
|
||||
Reference in new issue
Block a user