Files
Dune/game/truthtrance-force-count.ts
T
Quintine 89589878fc
Verify and publish checkpoint container / container (push) Canceled after 0s
Support board-wide Truthtrance force questions
2026-10-29 03:41:29 +11:00

244 lines
8.7 KiB
TypeScript

import { location, territory, validLocation } from './board';
import type { FactionId } from './catalog';
export type ForceCountFact = {
kind: 'forceCount';
zone:
| { kind: 'reserves' }
| { kind: 'tanks' }
| { kind: 'board' }
| { kind: 'territory'; territory: string }
| { 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' || zone.kind === 'board') {
if (!exactKeys(zone, ['kind']))
throw new Error('Reserve, Tanks and board facts cannot name a 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']) ||
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, the board, a current territory, or a 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, boardLocations?: readonly ValidLocation[]) {
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.');
if (eliteState !== undefined && !record(eliteState.forces))
throw new Error('The selected elite board force pool is malformed.');
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 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');
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');
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,
boardLocations?: readonly ValidLocation[],
): boolean {
const unavailable = forceCountCounterError(player, fact.counter);
if (unavailable) throw new Error(unavailable);
const pool = chosenPool(player, fact, boardLocations);
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'
: 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}`;
}