Support board-wide Truthtrance force questions
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
f1007a4ea8
commit
89589878fc
12 files changed
+144
-29
No files matched your search
@@ -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' } });
|
||||
}}>
|
||||
<option value="reserves">Reserves</option>
|
||||
<option value="tanks">Tleilaxu Tanks</option>
|
||||
<option value="board">Entire Arrakis board</option>
|
||||
<option value="territory">Entire board territory</option>
|
||||
<option value="location">One board location</option>
|
||||
</select></label>
|
||||
@@ -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 })} /></label>
|
||||
<p id={`${id}-help`} className="fine">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.</p>
|
||||
{value.zone.kind === 'board' && <p className="fine">The board includes placed Mobile Stronghold and revealed Discovery interiors. Reserves, Tanks and Homeworlds do not count.</p>}
|
||||
{!!forceCountCounterError(player, 'elite') && <p className="fine">This table does not track this faction’s starred counters separately. Choose all physical force counters.</p>}
|
||||
{error && <p id={`${id}-error`} className="notice" role="alert">{error}</p>}
|
||||
</>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'] },
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { cardPresentation } from '../game/card-presentation';
|
||||
import { cardPresentation, richeseCardActionBlock } from '../game/card-presentation';
|
||||
import type { Card } from '../game/cards';
|
||||
import { RICHESE_CARD_DEFINITIONS, richeseCards } from '../game/richese-cards';
|
||||
|
||||
@@ -36,7 +36,11 @@ void test('canonical Richese inspectors describe available previews and retain c
|
||||
const karamaAvailability = cardPresentation(karama.card).availability;
|
||||
assert.match(saphoAvailability ?? '', /supported Richese and expansion starts are available as unfinished previews/i);
|
||||
assert.match(saphoAvailability ?? '', /Once Around first or last/);
|
||||
assert.match(saphoAvailability ?? '', /other phase or auction modes remain unfinished/);
|
||||
assert.match(saphoAvailability ?? '', /both combatants finish final battle actions/);
|
||||
assert.match(saphoAvailability ?? '', /older battles retain their pre-plan opportunity/);
|
||||
const saphoAction = richeseCardActionBlock(sapho.card);
|
||||
assert.match(saphoAction ?? '', /both combatants finish their final battle actions/);
|
||||
assert.match(saphoAction ?? '', /older battles retain their pre-plan opportunity/);
|
||||
assert.match(karamaAvailability ?? '', /supported Richese and expansion starts are available as unfinished previews/i);
|
||||
assert.match(karamaAvailability ?? '', /bounded card handlers/);
|
||||
for (const definition of RICHESE_CARD_DEFINITIONS)
|
||||
|
||||
@@ -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', 'territory', 'location', 'total', 'normal', 'elite', 'eq', 'gte', 'lte'])
|
||||
for (const value of ['reserves', 'tanks', 'board', '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,15 @@ void test('force fact controls name physical pools and comparisons without revea
|
||||
assert.match(html, /min="0"/); assert.match(html, /max="9007199254740991"/);
|
||||
});
|
||||
|
||||
void test('the whole board choice needs no selected territory or sector', () => {
|
||||
const board: ForceCountFact = { ...fact, zone: { kind: 'board' } };
|
||||
assert.equal(forceCountInputError(board, {}, player), null);
|
||||
const html = markup(board);
|
||||
assert.match(html, /Entire Arrakis board/);
|
||||
assert.doesNotMatch(html, /Force territory/);
|
||||
assert.doesNotMatch(html, /Force sector/);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -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: 'board' },
|
||||
{ kind: 'territory', territory: 'imperial_basin' },
|
||||
{ kind: 'location', territory: 'imperial_basin', sector: 9 },
|
||||
])
|
||||
@@ -48,6 +49,7 @@ 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: 'board', territory: 'arrakeen' } },
|
||||
{ ...input, zone: { kind: 'territory', territory: 'unknown' } },
|
||||
{ ...input, zone: { kind: 'territory', territory: 'arrakeen', sector: 10 } },
|
||||
{
|
||||
@@ -85,6 +87,24 @@ void test('parser accepts exact live pools and rejects extra, mutually exclusive
|
||||
assert.throws(() => parseForceCountFact({ ...input, value }, locations));
|
||||
});
|
||||
|
||||
void test('board pool sums only the supplied active locations and preserves typed counters', () => {
|
||||
const p: ForceCountPlayer = { faction: 'ixians', reserves: 6, tanks: 2,
|
||||
forces: { 'arrakeen:10': 4, 'imperial_basin:9': 3, 'hidden_mobile_stronghold:0': 6, 'cistern:0': 5, 'invalid:0': 10 },
|
||||
elites: { reserves: 0, tanks: 0, forces: { 'arrakeen:10': 1, 'imperial_basin:9': 2, 'hidden_mobile_stronghold:0': 3 } } };
|
||||
const zone = { kind: 'board' } as const;
|
||||
for (const [counter, n] of [['total', 7], ['normal', 4], ['elite', 3]] as const)
|
||||
assert.equal(forceCountFactMatches(p, fact(zone, counter, 'eq', n), locations), true);
|
||||
const withHms = [...locations, { territory: 'hidden_mobile_stronghold', sector: 0 }];
|
||||
assert.equal(forceCountFactMatches(p, fact(zone, 'total', 'eq', 13), withHms), true);
|
||||
assert.equal(forceCountFactMatches(p, fact(zone, 'elite', 'eq', 6), withHms), true);
|
||||
assert.throws(() => forceCountFactMatches(p, fact(zone)), /board locations/);
|
||||
assert.throws(() => forceCountFactMatches(p, fact(zone), []), /board locations/);
|
||||
assert.throws(() => forceCountFactMatches(p, fact(zone), [...locations, locations[0]]), /duplicate/);
|
||||
assert.throws(() => forceCountFactMatches(p, fact(zone), [{ territory: 'arrakeen', sector: 999 }]), /malformed/);
|
||||
assert.equal(forceCountFactText(fact(zone, 'total', 'eq', 7)),
|
||||
'you currently have exactly 7 physical forces on the current Arrakis board');
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -4,7 +4,8 @@ import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game }
|
||||
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 { truthFactAnswer, type TruthFact } from '../game/truthtrance';
|
||||
import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
|
||||
import { knowledgeGame, knowledgePhysical } from './truthtrance-knowledge-fixture';
|
||||
import { completedIxSkillsGame, assertIxSkillsCustody } from './ix-skills-fixture';
|
||||
import { discoveryFixture, enterDiscoveryCollection } from './fixture-discovery';
|
||||
@@ -114,6 +115,27 @@ void test('a whole-territory Basic question counts separate sectors and every bo
|
||||
}
|
||||
});
|
||||
|
||||
void test('a whole-board Basic force question counts multiple territories and every bot answers the actual fact', () => {
|
||||
const initial = knowledgeGame(false);
|
||||
const fremen = player(initial, 'f');
|
||||
fremen.forces = { 'wind_pass:14': 4, 'imperial_basin:9': 6 };
|
||||
const zone: ForceFact['zone'] = { kind: 'board' };
|
||||
const question = fact(zone, 'total', 10);
|
||||
assert.equal(truthFactAnswer(fremen, question), 'unknown');
|
||||
assert.equal(truthFactAnswer(fremen, { kind: 'and', terms: [question, question] }), 'unknown');
|
||||
answer(ask(initial, 'f', question), 'f', 'yes');
|
||||
answer(ask(initial, 'f', fact(zone, 'total', 11, 'gte')), 'f', 'no');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const waiting = ask(initial, 'f', question);
|
||||
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.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');
|
||||
@@ -262,6 +284,12 @@ void test('the live HMS interior is an exact physical location distinct from its
|
||||
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);
|
||||
answer(ask(initial, 'i', fact({ kind: 'board' }, 'total', 6), 'e'), 'i', 'yes');
|
||||
answer(ask(initial, 'i', fact({ kind: 'board' }, 'elite', 3), 'e'), 'i', 'yes');
|
||||
const unplaced = structuredClone(initial);
|
||||
unplaced.mobileStronghold!.location = null;
|
||||
assert.equal(truthFactAnswer(player(unplaced, 'i'), fact({ kind: 'board' }, 'total', 0),
|
||||
truthKnowledgeOf(unplaced, player(unplaced, 'i'))), 'yes');
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user