Files
Dune/game/force-inventory.ts

104 lines
4.5 KiB
TypeScript

import { splitLocation, territory } from './board';
import type { FactionId } from './catalog';
import { specialForceName } from './combat';
import { isAdvisor } from './advisors';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { isBoardNoFieldLocation, type NoFieldLocation } from './richese-no-field';
/** Public physical custody only. No private cards, commitments or token faces. */
export type PublicForcePlayer = {
id: string;
name: string;
faction: FactionId;
reserves: number;
tanks: number;
forces: Readonly<Record<string, number>>;
elites?: { reserves: number; tanks: number; forces: Readonly<Record<string, number>> };
advisors?: Record<string, { lockedTurn?: number }>;
noField?: { deployed: { location: NoFieldLocation } | null } | null;
};
export type PublicForceWorld = {
id: string;
native: string;
card: string;
forces: Readonly<Record<string, { normal: number; elite: number }>>;
};
export type ForcePool = {
id: string;
name: string;
total: number;
normal: number;
elite: number;
advisors: boolean;
};
export function forceCounterName(faction: FactionId, kind: 'force' | 'special' | 'advisor') {
if (kind === 'advisor') return 'Advisor';
if (kind === 'special') return specialForceName(faction);
return faction === 'ixians' ? 'Suboid' : faction === 'beneGesserit' ? 'Fighter' : 'Force';
}
function locationName(id: string, sector: number) {
if (!Number.isSafeInteger(sector) || sector < 0) throw new Error('Invalid force location.');
const name = territory(id).name;
return sector > 0 ? `${name} · sector ${sector}` : name;
}
function markerLocationName(marker: NoFieldLocation) {
if (isBoardNoFieldLocation(marker)) return locationName(marker.territory, marker.sector);
const card = HOMEWORLD_CARDS.find(candidate => marker.homeworld ===
`homeworld:${candidate.faction}${candidate.id === 'salusa_secundus' ? ':salusa' : ''}`);
if (!card) throw new Error('Unknown No-Field Homeworld.');
return card.name;
}
function pool(id: string, name: string, total: number, elite = 0, advisors = false): ForcePool {
if (![total, elite].every(n => Number.isSafeInteger(n) && n >= 0) || elite > total)
throw new Error('Invalid public force count.');
return { id, name, total, normal: total - elite, elite, advisors };
}
/** Native Homeworlds subdivide reserves; only foreign deployments add a pool.
* Concealed No-Fields are markers, never physical counters or inferred values.
* A malformed projection produces no fabricated totals and cannot crash a table. */
export function forceInventory(player: PublicForcePlayer, worlds: readonly PublicForceWorld[] | null = null) {
try {
const reserves = pool('reserves', 'Reserves', player.reserves, player.elites?.reserves);
const tanks = pool('tanks', 'Tleilaxu Tanks', player.tanks, player.elites?.tanks);
const rows = [reserves, tanks];
for (const key of [...new Set([...Object.keys(player.forces), ...Object.keys(player.elites?.forces ?? {})])].sort()) {
const { territory: id, sector } = splitLocation(key);
const row = pool(key, locationName(id, sector), player.forces[key] ?? 0,
player.elites?.forces[key], isAdvisor(player, id));
if (row.total > 0) rows.push(row);
}
const nativeReserves: ForcePool[] = [];
for (const world of worlds ?? []) {
const force = world.forces[player.id];
if (!force) continue;
const card = HOMEWORLD_CARDS.find(c => c.id === world.card);
if (!card) throw new Error('Unknown Homeworld.');
const row = pool(world.id, card.name, force.normal + force.elite, force.elite);
if (world.native === player.id) nativeReserves.push(row);
else if (row.total > 0) rows.push(row);
}
if (nativeReserves.length && (nativeReserves.reduce((n, r) => n + r.total, 0) !== reserves.total ||
nativeReserves.reduce((n, r) => n + r.elite, 0) !== reserves.elite))
throw new Error('Native reserves disagree with their Homeworld breakdown.');
const marker = player.noField?.deployed?.location;
const total = rows.reduce((n, r) => n + r.total, 0);
if (!Number.isSafeInteger(total)) throw new Error('Invalid force inventory.');
return {
rows, nativeReserves,
total,
normal: rows.reduce((n, r) => n + r.normal, 0),
elite: rows.reduce((n, r) => n + r.elite, 0),
advisors: rows.reduce((n, r) => n + (r.advisors ? r.total : 0), 0),
split: !!player.elites,
marker: marker ? { name: markerLocationName(marker) } : null,
};
} catch {
return null;
}
}