Files
Dune/game/reinforcements-homeworld.ts
T
Quintine 97c2b67650 Compose classic independent cards with World Nexus rules
Retain independent36, original Homeworlds and Nexus12 through classic fresh setup with optional Skills and phase modules. Resolve own-World Reinforcements fees before computing temporary Sardaukar roles, preserving original Cunning declarations, printed traits and physical counters. Record the provisional ordinary-nonrole-first policy and align human/AI quotes and captions.

Validation: types/lint/build, 128 affected tests across 15 files, bounded rule-source review and finite original-action smoke for Recruits, Harass, local-fee Cunning, all four legal policies and rendered source controls. Verified runtime c219ec587a54b44079539168d93835a5af904c8afb4de58bcec0a3d60c809f0c; documentation-only proof update 8ee21598e7be0c89a08fbad50786c346ebc8ae705492d8b31ea00fae0ffefa3c. Full modes, combinations, assurance and deployed acceptance remain open. No saved-room/provider changes.
2026-10-18 10:20:08 +11:00

81 lines
4.3 KiB
TypeScript

import { validCombatForces, type CombatForces } from './combat';
import {
homeworldForceGroups, type HomeworldCustody, type HomeworldCustodyContext,
type HomeworldForces,
} from './homeworld-custody';
import {
NativeReserveError, quoteNativeReserveWithdrawal, type NativeReserveSelections,
} from './homeworld-native-reserves';
import { quoteReinforcements, ReinforcementsError } from './reinforcements';
type Cost = ReturnType<typeof quoteReinforcements>;
function nativeCost(context: HomeworldCustodyContext, actor: string): Cost {
const seat = context.players.find(player => player.id === actor);
if (!seat) throw new NativeReserveError('Reinforcements needs its seated native reserve owner.');
return quoteReinforcements(seat.reserves - seat.eliteReserves, seat.eliteReserves);
}
function sourceOptions(context: HomeworldCustodyContext, custody: HomeworldCustody,
actor: string, cost: Cost): NativeReserveSelections[] {
const homes = homeworldForceGroups(context, custody).filter(home => home.native === actor);
if (homes.length === 1) return [{ [homes[0].id]: { normal: cost.normal, elite: cost.elite } }];
if (homes.length !== 2) throw new NativeReserveError('Choose the actual native Homeworld reserve sources.');
const [first, second] = homes;
const firstPool = first.forces[actor], secondPool = second.forces[actor];
const options: NativeReserveSelections[] = [];
// At most six allocations for a three-counter typed cost. Both native World
// entries remain explicit, including zero, so clients never guess a split.
for (let normal = Math.max(0, cost.normal - secondPool.normal);
normal <= Math.min(cost.normal, firstPool.normal); normal++) {
for (let elite = Math.max(0, cost.elite - secondPool.elite);
elite <= Math.min(cost.elite, firstPool.elite); elite++) {
options.push({
[first.id]: { normal, elite },
[second.id]: { normal: cost.normal - normal, elite: cost.elite - elite },
});
}
}
return options;
}
/** Public physical alternatives for the existing normal-first fee. Enumeration
* does not select a source, move counters or grant permission to play a card. */
export function reinforcementsHomeworldSources(context: HomeworldCustodyContext,
custody: HomeworldCustody, actor: string): NativeReserveSelections[] {
return sourceOptions(context, custody, actor, nativeCost(context, actor));
}
/** Exact native withdrawal; the caller commits matching Tanks and observes
* population only after this whole fee. Ambiguous sources must be declared. */
export function quoteReinforcementsHomeworldCost(context: HomeworldCustodyContext,
custody: HomeworldCustody, actor: string, selections?: NativeReserveSelections) {
const cost = nativeCost(context, actor);
let sources = selections;
if (sources === undefined) {
const options = sourceOptions(context, custody, actor, cost);
if (options.length !== 1)
throw new NativeReserveError('Choose the Homeworld sources for Reinforcements; more than one allocation is possible.');
sources = options[0];
}
const native = quoteNativeReserveWithdrawal(context, custody, actor,
{ normal: cost.normal, elite: cost.elite }, sources);
return { ...cost, sources, native };
}
/** On an own World the reserve fee and combat army share physical counters.
* Reserve the fee first, but retain the original sealed support/strength traits.
* Provisionally spend ordinary counters without a temporary role first, then
* remove that role only from converted counters actually used to pay the fee.
* Foreign-World and Arrakis callers pass no on-location cost. */
export function reinforcementsBattleForces(forces: CombatForces,
onLocationCost?: HomeworldForces): CombatForces {
if (!onLocationCost) return forces;
const { normal, elite } = onLocationCost;
if (!validCombatForces(forces) || !Number.isSafeInteger(normal) || normal < 0 || !Number.isSafeInteger(elite) || elite < 0 ||
normal > forces.normal || elite > forces.elite)
throw new ReinforcementsError('The reserve fee cannot also fund the physical Homeworld dial.');
if (normal + elite === 0) return forces;
const remainingNormal = forces.normal - normal;
return { ...forces, normal: remainingNormal, elite: forces.elite - elite,
...(forces.temporaryElite === undefined ? {} : {temporaryElite: Math.min(forces.temporaryElite, remainingNormal)}) };
}