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:
Quintine committed 2026-09-21 15:39:39 +10:00
1 parent d1bf33d0bc
commit 7136c8dd19
14 files changed
+1112 -17

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useId } from 'react';
import { gameTerritories, type MobileBoard } from '@/game/board';
import { forceCountCounterError, parseForceCountFact, type ForceCountFact, type ForceCountPlayer } from '@/game/truthtrance-force-count';
import { Input } from './ui/input';
type CounterPlayer = Pick<ForceCountPlayer, 'faction' | 'elites'>;
export function forceCountInputError(value: ForceCountFact, game: MobileBoard, player: CounterPlayer): string | null {
try {
parseForceCountFact(value, gameTerritories(game).flatMap(t =>
t.sectors.map(sector => ({ territory: t.id, sector }))));
return forceCountCounterError(player, value.counter);
} catch (error) {
return error instanceof Error ? error.message : 'Choose a valid physical force count.';
}
}
export function ForceCountFields({ value, game, player, onChange }: {
value: ForceCountFact;
game: MobileBoard;
player: CounterPlayer;
onChange: (value: ForceCountFact) => void;
}) {
const id = useId();
const territories = gameTerritories(game);
const zone = value.zone;
const selected = zone.kind === 'location' ? territories.find(t => t.id === zone.territory) : null;
const error = forceCountInputError(value, game, player);
return <>
<label>Force pool<select value={value.zone.kind} onChange={event => {
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' } });
}}>
<option value="reserves">Reserves</option>
<option value="tanks">Tleilaxu Tanks</option>
<option value="location">One board location</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)!;
onChange({ ...value, zone: { kind: 'location', territory: target.id, sector: target.sectors[0] } });
}}>{territories.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}</select></label>
<label>Force sector<select value={value.zone.sector} onChange={event => {
if (value.zone.kind === 'location') onChange({ ...value, zone: { ...value.zone, sector: Number(event.target.value) } });
}}>{selected?.sectors.map(sector => <option key={sector} value={sector}>{sector === 0 ? 'Interior / whole location' : `Sector ${sector}`}</option>)}</select></label>
</>}
<label>Counter type<select value={value.counter} onChange={event => onChange({ ...value, counter: event.target.value as ForceCountFact['counter'] })}>
<option value="total">All physical force counters</option>
<option value="normal" disabled={!!forceCountCounterError(player, 'normal')}>Normal counters</option>
<option value="elite" disabled={!!forceCountCounterError(player, 'elite')}>Elite counters</option>
</select></label>
<label>Compare force count<select value={value.compare} onChange={event => onChange({ ...value, compare: event.target.value as ForceCountFact['compare'] })}>
<option value="eq">Exactly</option><option value="gte">At least</option><option value="lte">At most</option>
</select></label>
<label htmlFor={`${id}-count`}>Number of force counters<Input id={`${id}-count`} type="number" min={0} max={Number.MAX_SAFE_INTEGER} step={1} required
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>
{!!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>}
</>;
}
+10 -2
View File
@@ -3,6 +3,7 @@ import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput }
import { BattleClaimFields } from './battle-promises';
import { CardCountFields } from './truthtrance-card-count';
import { KnowledgeFactFields } from './truthtrance-knowledge';
import { ForceCountFields, forceCountInputError } from './truthtrance-force-count';
import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge';
import type { PlanClaim } from '@/game/battle-promises';
import { useId, useState } from 'react';
@@ -85,7 +86,8 @@ export function Truthtrance({
(!Number.isSafeInteger(fact.value) || fact.value < 0);
const invalidFact = clauses
.slice(0, join === 'single' ? 1 : 2)
.some((fact) => invalidSpice(fact) || (isKnowledgeFact(fact) && !!knowledgeFactInputError(fact)));
.some((fact) => invalidSpice(fact) || (isKnowledgeFact(fact) && !!knowledgeFactInputError(fact)) ||
(fact.kind === 'forceCount' && !!forceCountInputError(fact, g, g.players.find(p => p.id === target)!)));
const leaderName = (id: string) =>
id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
@@ -442,6 +444,8 @@ export function Truthtrance({
? { kind: 'stormDial', compare: 'gte', value: 0 }
: e.target.value === 'stormForecast'
? { kind: 'stormForecast', compare: 'gte', value: 1 }
: e.target.value === 'forceCount'
? { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 }
: e.target.value === 'spice'
? {
kind: 'spice',
@@ -472,6 +476,7 @@ export function Truthtrance({
<option value="spice">
Current personal spice
</option>
<option value="forceCount">Current physical forces</option>
</select>
</label>
{c.kind === 'hand' ? (
@@ -506,6 +511,9 @@ export function Truthtrance({
)
}
/>
) : c.kind === 'forceCount' ? (
<ForceCountFields value={c} game={g} player={g.players.find(p => p.id === target)!}
onChange={next => setClauses(clauses.map((old, n) => n === index ? next : old))} />
) : c.kind === 'prediction' || c.kind === 'stormDial' || c.kind === 'stormForecast' ? (
<KnowledgeFactFields
value={c}
@@ -622,7 +630,7 @@ export function Truthtrance({
))}
<p className="notice">
{invalidFact
? 'Enter a valid whole amount before asking this question.'
? 'Resolve the marked fields before asking this question.'
: truthQuestionText(question, leaderName)}
</p>
</>