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>; elites?: Readonly<{ reserves: number; tanks: number; forces: Readonly>; }>; }; type ValidLocation = Readonly<{ territory: string; sector: number }>; const splitFactions = new Set(['emperor', 'fremen', 'ixians']); function record(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function exactKeys( value: Record, 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, 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(); 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}`; }