Add whole-territory Truthtrance force questions
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 03:37:52 +11:00
1 parent 7552bb5394
commit f1007a4ea8
9 files changed
+149 -21

No files matched your search

+7 -1
View File
@@ -33,12 +33,18 @@ export function ForceCountFields({ value, game, player, onChange }: {
const kind = event.target.value;
onChange({ ...value, zone: kind === 'location'
? { kind, territory: territories[0].id, sector: territories[0].sectors[0] }
: { kind: kind as 'reserves' | 'tanks' } });
: kind === 'territory' ? { kind, territory: territories[0].id }
: { kind: kind as 'reserves' | 'tanks' } });
}}>
<option value="reserves">Reserves</option>
<option value="tanks">Tleilaxu Tanks</option>
<option value="territory">Entire board territory</option>
<option value="location">One board location</option>
</select></label>
{value.zone.kind === 'territory' &&
<label>Force territory<select value={value.zone.territory} onChange={event =>
onChange({ ...value, zone: { kind: 'territory', territory: event.target.value } })
}>{territories.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}</select></label>}
{value.zone.kind === 'location' && <>
<label>Force territory<select value={value.zone.territory} onChange={event => {
const target = territories.find(t => t.id === event.target.value)!;
+10
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**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
physical counters, including advisors and tracked elites, while excluding
concealed No-Fields. Mobile Stronghold and revealed Discovery interiors remain
separate locations. Twenty-four focused cases pass, including staged native
games and legal bot answers; typecheck, lint and the production build pass.
Full Truthtrance and mode acceptance remain open.
[Scope](TRUTHTRANCE_FORCE_FACTS.md#whole-territory-questions--29-october-2026).
**Basic Fremen and Ecaz final victory — 29 October. Prototyped / Partial.**
The documented provisional interpretation permits allied Fremen and Ecaz to
co-occupy Habbanya, as well as Sietch Tabr, for Fremen's final-turn victory.
+22 -6
View File
@@ -1,8 +1,8 @@
# 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
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
prose interpretation, future commitments or all Truthtrance interactions.
## Source and semantics
@@ -22,12 +22,28 @@ 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
select a whole territory or an exact territory and sector, including an
available HMS interior or revealed Discovery location. Whole territories count
each constituent sector once; HMS and Discovery interiors are separate from
their outside territories. Individual Homeworld populations remain outside this
fact. Changing the HMS pointer does not move the counters inside to the outside
territory.
## Whole-territory questions — 29 October 2026
The existing game-related Yes/No question authority also supports current
physical counts across a selected territory's sectors. Total, normal and elite
use the same verified physical pools and public split guard as exact locations.
The player selects the room's currently available territory; invalid or hidden
interiors cannot be queried. The answer remains an observation, with the same
advisor and No-Field treatment as the original fact. The question is a useful
structured form, not a distinct printed power or full freeform interpretation.
Focused pure, form and real Basic and typed-game paths are in
`tests/truthtrance-force-count.test.ts`, `tests/truthtrance-force-controls.test.tsx`
and `tests/truthtrance-force-engine.test.ts`; staged board positions preserve
the physical counters, and all four existing bot answer paths consume the
private expected answer.
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
+1 -1
View File
@@ -195,7 +195,7 @@ export const FACTION_RULES: Record<
'After losing a qualifying battle with no traitor called and the opposing leader surviving, you may reveal a held Traitor Card naming a different leader from that opposing faction. A living target dies and pays its printed strength from the bank; an already-dead target pays nothing.',
'During Mentat Pause, set the revealed card aside face up as that faction’s use marker and draw its replacement. Use this advantage once against each faction; Karama cannot prevent it.',
'Once per game after a battle loss with a winner, special Karama can force kept/discarded subsets of that opponent’s public played cards. Owned selectors, native cost and cleanup are connected; mandatory named-card disposal has explicitly provisional precedence.',
'Advanced assassination is available in an opted-in development preview. The duration of forfeiture after calling a normal traitor and exceptional leader-custody interactions remain unresolved.',
'Advanced assassination is available in an opted-in development preview. Revealing your Traitor Card normally forfeits this advantage for the rest of the game. Exceptional leader-custody interactions remain unfinished.',
],
},
};
+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 or an exact board location.',
summary: 'Ask about current physical counters in reserves, the Tanks, 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. A board question names both territory and 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 an entire territory or name an exact 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.',
'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, 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: '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: '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'] },
+30 -8
View File
@@ -6,6 +6,7 @@ export type ForceCountFact = {
zone:
| { kind: 'reserves' }
| { kind: 'tanks' }
| { kind: 'territory'; territory: string }
| { kind: 'location'; territory: string; sector: number };
counter: 'total' | 'normal' | 'elite';
compare: 'eq' | 'gte' | 'lte';
@@ -83,6 +84,14 @@ export function parseForceCountFact(
if (!exactKeys(zone, ['kind']))
throw new Error('Reserve and Tanks facts cannot name a board location.');
parsedZone = { kind: zone.kind };
} else if (zone.kind === 'territory') {
if (
!exactKeys(zone, ['kind', 'territory']) ||
typeof zone.territory !== 'string' ||
!validLocations.some(candidate => candidate.territory === zone.territory)
)
throw new Error('Choose a current board territory.');
parsedZone = { kind: 'territory', territory: zone.territory };
} else if (zone.kind === 'location') {
if (
!exactKeys(zone, ['kind', 'territory', 'sector']) ||
@@ -101,7 +110,7 @@ export function parseForceCountFact(
sector: zone.sector as number,
};
} else {
throw new Error('Choose reserves, the Tanks, or a current board location.');
throw new Error('Choose reserves, the Tanks, a current territory, or a board location.');
}
return {
kind: 'forceCount',
@@ -140,13 +149,24 @@ function chosenPool(player: ForceCountPlayer, fact: ForceCountFact) {
} 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;
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];
let total = 0, elite = 0;
for (const sector of sectors) {
const key = location(fact.zone.territory, 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');
if (sectorElite > sectorTotal)
throw new Error('The selected elite force count exceeds its physical force pool.');
total = count(total + sectorTotal, 'The selected physical force count');
elite = count(elite + sectorElite, 'The selected elite force count');
}
totalValue = total;
eliteValue = elite;
}
const total = count(totalValue, 'The selected physical force count');
const elite = count(eliteValue, 'The selected elite force count');
@@ -197,6 +217,8 @@ export function forceCountFactText(fact: ForceCountFact): string {
? 'in your reserves'
: fact.zone.kind === 'tanks'
? 'in the Tleilaxu Tanks'
: `in ${territory(fact.zone.territory).name}, sector ${fact.zone.sector}`;
: 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}`;
}
+12 -1
View File
@@ -14,7 +14,7 @@ function markup(value = fact, game: MobileBoard = {}, target = player) {
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'])
for (const value of ['reserves', 'tanks', 'territory', '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/);
@@ -23,6 +23,17 @@ void test('force fact controls name physical pools and comparisons without revea
assert.match(html, /min="0"/); assert.match(html, /max="9007199254740991"/);
});
void test('whole-territory choice names the territory without requiring a sector', () => {
const whole: ForceCountFact = { ...fact, zone: { kind: 'territory', territory: 'wind_pass' } };
assert.equal(forceCountInputError(whole, {}, player), null);
const html = markup(whole);
assert.match(html, /Entire board territory/);
assert.match(html, /Force territory/);
assert.match(html, /Wind Pass/);
assert.doesNotMatch(html, /Force sector/);
assert.ok(forceCountInputError({ ...whole, zone: { kind: 'territory', territory: 'hidden_mobile_stronghold' } }, {}, player));
});
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/);
+24
View File
@@ -37,6 +37,7 @@ void test('parser accepts exact live pools and rejects extra, mutually exclusive
for (const zone of [
{ kind: 'reserves' },
{ kind: 'tanks' },
{ kind: 'territory', territory: 'imperial_basin' },
{ kind: 'location', territory: 'imperial_basin', sector: 9 },
])
assert.deepEqual(
@@ -47,6 +48,8 @@ void test('parser accepts exact live pools and rejects extra, mutually exclusive
for (const malformed of [
{ ...input, privateHint: true },
{ ...input, zone: { kind: 'reserves', territory: 'arrakeen' } },
{ ...input, zone: { kind: 'territory', territory: 'unknown' } },
{ ...input, zone: { kind: 'territory', territory: 'arrakeen', sector: 10 } },
{
...input,
zone: {
@@ -82,6 +85,27 @@ void test('parser accepts exact live pools and rejects extra, mutually exclusive
assert.throws(() => parseForceCountFact({ ...input, value }, locations));
});
void test('territory-wide facts sum physical sectors and their typed subsets without counting nearby or interior territories', () => {
const player: ForceCountPlayer = {
faction: 'ixians', reserves: 9, tanks: 0,
forces: { 'wind_pass:14': 4, 'wind_pass:15': 3, 'imperial_basin:9': 5, 'hidden_mobile_stronghold:0': 2 },
elites: { reserves: 1, tanks: 0, forces: { 'wind_pass:14': 1, 'wind_pass:15': 2 } },
};
const zone = { kind: 'territory', territory: 'wind_pass' } as const;
for (const [counter, value] of [['total', 7], ['normal', 4], ['elite', 3]] as const) {
assert.equal(forceCountFactMatches(player, fact(zone, counter, 'eq', value)), true);
assert.equal(forceCountFactMatches(player, fact(zone, counter, 'gte', value + 1)), false);
}
assert.equal(forceCountFactText(fact(zone, 'total', 'eq', 7)),
'you currently have exactly 7 physical forces in Wind Pass across all sectors');
assert.throws(() => forceCountFactMatches({ ...player,
elites: { ...player.elites!, forces: { 'wind_pass:14': 5 } } }, fact(zone)), /exceeds/);
assert.throws(() => forceCountFactMatches({ ...player,
forces: { ...player.forces, 'wind_pass:14': null } } as unknown as ForceCountPlayer, fact(zone)), /nonnegative/);
assert.throws(() => forceCountFactMatches({ ...player,
elites: { ...player.elites!, forces: { 'wind_pass:15': null } } } as unknown as ForceCountPlayer, fact(zone)), /nonnegative/);
});
void test('current totals include elites while normal counts subtract the tracked physical subset in every pool', () => {
const player: ForceCountPlayer = {
faction: 'ixians',
+39
View File
@@ -94,6 +94,40 @@ void test('Truthtrance counts physical total, normal and elite forces in reserve
}
});
void test('a whole-territory Basic question counts separate sectors and every bot answers the same real fact', () => {
const initial = knowledgeGame(false);
const fremen = player(initial, 'f');
// Preserve the original ten on-board and ten reserve Fremen counters.
fremen.forces = { 'wind_pass:14': 4, 'wind_pass:15': 6 };
const zone: ForceFact['zone'] = { kind: 'territory', territory: 'wind_pass' };
answer(ask(initial, 'f', fact(zone, 'total', 10)), 'f', 'yes');
answer(ask(initial, 'f', fact(zone, 'total', 11, 'gte')), 'f', 'no');
for (const difficulty of DIFFICULTIES) {
const waiting = ask(initial, 'f', fact(zone, 'total', 10));
const view = viewGame(waiting, 'f');
view.players.find(candidate => candidate.id === 'f')!.bot = difficulty;
const response = botActions(view);
assert.deepEqual(response, [{ type: 'truthAnswer', answer: 'yes' }]);
const done = act(waiting, 'f', response[0]);
assert.equal(done.truthtrance, null);
assert.equal(done.truthHistory!.at(-1)!.answer, 'yes');
}
});
void test('whole-territory typed facts aggregate elite and normal counters across sectors', () => {
const initial = typedGame();
const fremen = player(initial, 'f');
const before = knowledgePhysical(initial);
fremen.forces = { 'wind_pass:14': 4, 'wind_pass:15': 3 };
fremen.elites!.reserves = 0;
fremen.elites!.forces = { 'wind_pass:14': 1, 'wind_pass:15': 1 };
assert.deepEqual(knowledgePhysical(initial), before);
const zone: ForceFact['zone'] = { kind: 'territory', territory: 'wind_pass' };
answer(ask(initial, 'f', fact(zone, 'total', 7)), 'f', 'yes');
answer(ask(initial, 'f', fact(zone, 'normal', 5)), 'f', 'yes');
answer(ask(initial, 'f', fact(zone, 'elite', 2)), '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);
@@ -230,6 +264,8 @@ void test('the live HMS interior is an exact physical location distinct from its
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: 'territory', territory: MOBILE_STRONGHOLD }, 'total', 6), 'e'), 'i', 'yes');
answer(ask(initial, 'i', fact({ kind: 'territory', territory: 'polar_sink' }, 'total', 0), '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',
@@ -243,10 +279,13 @@ void test('a Discovery location becomes queryable only after its genuine reveal'
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) } });
reject(hidden, 'g', { type: 'truthAsk', question: { kind: 'fact', target: 'a',
fact: fact({ kind: 'territory', territory: 'cistern' }, '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');
answer(ask(game, 'a', fact({ kind: 'territory', territory: 'cistern' }, 'total', 0), 'g'), 'a', 'yes');
});
void test('Homeworld reserve facts read the faction aggregate and do not add a separate Homeworld query', () => {