Replace one unused movement entitlement by the selected card mode and retain independent Hajr movement after a completed two-group flight. Preserve distinct cohorts and later legal reuse. Adopt supplied Advanced immediate-after-movement Hajr play timing with explicit provisional online/card-group interpretation; Basic timing remains. Validation: frozen11-file132/132, types/lint/build and12 original-action/rendered-table programmes; four minimal policies retain both continuations. Actual cards are acquired by original auctions and source groups use funded shipments. Multiple Hajr cards, advisors, wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
172 lines
5.6 KiB
TypeScript
172 lines
5.6 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>>;
|
|
|
|
/** A two-group flight replaces one ordinary entitlement. Only its completed
|
|
* extra group earns a physical-move credit; Hajr remains an independent move. */
|
|
export function ordinaryMovementAllowance(hajrUsed: boolean, extraMoves = 0): number {
|
|
const limit = 1 + Number(hajrUsed) + extraMoves;
|
|
if (typeof hajrUsed !== 'boolean' || !Number.isSafeInteger(extraMoves) || extraMoves < 0 ||
|
|
!Number.isSafeInteger(limit)) throw new RangeError('Movement credits must be nonnegative safe integers.');
|
|
return limit;
|
|
}
|
|
|
|
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);
|
|
}
|