Files
Dune/game/ornithopter.ts
T
2026-09-08 07:37:52 +10:00

163 lines
5.1 KiB
TypeScript

export type OrnithopterMode = 'range3' | 'twoGroups';
export type MovementMarker = { tokenId: string; event: string; from: string };
export type MovementCohort = {
forces: Record<string, number>;
elites: Record<string, number>;
noField?: MovementMarker;
};
type Counts = Readonly<Record<string, number>>;
function validateCounts(counts: Counts, label: string) {
if (!counts || typeof counts !== 'object' || Array.isArray(counts))
throw new TypeError(`${label} must be a location count record.`);
let total = 0;
for (const [key, count] of Object.entries(counts)) {
if (!key || !Number.isSafeInteger(count) || count < 0)
throw new RangeError(
`${label} needs nonnegative safe integer counts at named locations.`,
);
total += count;
if (!Number.isSafeInteger(total))
throw new RangeError(`${label} total must remain a safe integer.`);
}
}
function validatePool(forces: Counts, elites: Counts, label: string) {
validateCounts(forces, `${label} forces`);
validateCounts(elites, `${label} elites`);
for (const [key, count] of Object.entries(elites))
if (count > (Object.hasOwn(forces, key) ? forces[key] : 0))
throw new RangeError(`${label} elite count exceeds physical forces.`);
}
function validateMarker(marker: MovementMarker | undefined) {
if (marker === undefined) return;
if (
!marker ||
typeof marker !== 'object' ||
Array.isArray(marker) ||
!['tokenId', 'event', 'from'].every(
(key) =>
typeof marker[key as keyof MovementMarker] === 'string' &&
marker[key as keyof MovementMarker].length > 0,
)
)
throw new TypeError('A marker needs its exact token ID, event and source.');
}
const countAt = (counts: Counts, key: string) =>
Object.hasOwn(counts, key) ? counts[key] : 0;
function requireSubset(
forces: Counts,
elites: Counts,
selected: Counts,
selectedElites: Counts,
label: string,
) {
for (const [key, amount] of Object.entries(selected)) {
const elite = countAt(selectedElites, key);
if (
elite > countAt(elites, key) ||
amount - elite > countAt(forces, key) - countAt(elites, key)
)
throw new RangeError(
`Selected ordinary or elite forces exceed ${label}.`,
);
}
}
function requireMarker(
cohort: MovementMarker | undefined,
selected: MovementMarker | undefined,
) {
validateMarker(cohort);
validateMarker(selected);
if (
selected &&
(!cohort ||
selected.tokenId !== cohort.tokenId ||
selected.event !== cohort.event ||
selected.from !== cohort.from)
)
throw new Error(
'The selected marker is not an unmoved member of this cohort.',
);
}
/**
* Snapshot the original unmoved physical units after choosing the first group.
* Forces include elites; both types are subtracted separately. No range, phase,
* routing, reaction timing or hidden No-Field value is read or inferred here.
*/
export function createRemainingCohort(
allForces: Counts,
allElites: Counts,
selectedForces: Counts,
selectedElites: Counts,
marker?: MovementMarker,
selectedMarker?: MovementMarker,
): MovementCohort {
validatePool(allForces, allElites, 'Original');
validatePool(selectedForces, selectedElites, 'Selected');
requireSubset(
allForces,
allElites,
selectedForces,
selectedElites,
'the original cohort',
);
requireMarker(marker, selectedMarker);
const remainder = (all: Counts, selected: Counts) =>
Object.fromEntries(
Object.entries(all)
.map(([key, count]) => [key, count - countAt(selected, key)] as const)
.filter(([, count]) => count > 0),
);
return {
forces: remainder(allForces, selectedForces),
elites: remainder(allElites, selectedElites),
...(marker && !selectedMarker
? {
noField: {
tokenId: marker.tokenId,
event: marker.event,
from: marker.from,
},
}
: {}),
};
}
/**
* A later group must fit both original unmoved quotas and current physical
* custody, independently for ordinary and elite forces. Arriving moved units
* never increase the quotas. The engine separately validates current marker
* custody and any losses/relocations that alter which original units survive.
* The optional marker is the selected marker, not the full hidden inventory.
*/
export function validateCohortSelection(
cohort: MovementCohort,
currentForces: Counts,
currentElites: Counts,
selectedForces: Counts,
selectedElites: Counts,
selectedMarker?: MovementMarker,
): void {
if (!cohort || typeof cohort !== 'object' || Array.isArray(cohort))
throw new TypeError('Supply an original unmoved cohort.');
validatePool(cohort.forces, cohort.elites, 'Cohort');
validatePool(currentForces, currentElites, 'Current');
validatePool(selectedForces, selectedElites, 'Selected');
requireSubset(
cohort.forces,
cohort.elites,
selectedForces,
selectedElites,
'the original unmoved cohort',
);
requireSubset(
currentForces,
currentElites,
selectedForces,
selectedElites,
'current physical custody',
);
requireMarker(cohort.noField, selectedMarker);
}