244 lines
8.7 KiB
TypeScript
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}`;
|
|
}
|