Files
Dune/game/ecaz-occupy-battle.ts
T

262 lines
8.8 KiB
TypeScript

import type { FactionId } from './catalog';
import {
casualtyOptions,
maxCombatDial,
maxCombatSupport,
validCombatForces,
type Casualties,
type CombatForces,
} from './combat';
export class EcazOccupyBattleError extends Error {
constructor(message: string) {
super(message);
this.name = 'EcazOccupyBattleError';
}
}
/** Native fighter pools for an eligible whole-territory battle. The canonical
* board quote excludes storm-obstructed armies and advisors before planning. */
export type EcazOccupyBattleSeat = Readonly<{
id: string;
faction: FactionId;
ally?: string | null;
forces: CombatForces;
/** Original concealed Richese marker: one battle presence even when the
* reserve-limited prospective pool is zero. Never ordinary zero fighters. */
noFieldPresence?: true;
}>;
export type EcazOccupyBattleInput = Readonly<{
advanced: boolean;
/** Original battle chooser, before Ecaz selects the coalition's plan actor. */
battleOrderActor: string;
ecaz: EcazOccupyBattleSeat;
ally: EcazOccupyBattleSeat;
lead: string;
canceled: boolean;
}>;
export type EcazOccupyBattleProfile = Readonly<{
advanced: boolean;
battleOrderActor: string;
ecaz: string;
ally: string;
lead: string;
planOwner: string;
payer: string;
forceOwner: string;
canceled: boolean;
ecazForces: CombatForces;
allyForces: CombatForces;
allyNoFieldPresence?: true;
/** Only the variable dial's physical counters, never a merged army. */
forces: CombatForces;
fixedEcazDial: number;
maxDial: number;
maxSupport: number;
}>;
export type EcazOccupyDialQuote = {
forceOwner: string;
payer: string;
fixedEcazDial: number;
variableDial: number;
support: number;
options: Casualties[];
};
export type EcazOccupyDialOption = {
dial: number;
support: number;
variableDial: number;
};
export type EcazOccupyOutcomeInput = Readonly<{
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion';
/** Whether this coalition, not necessarily its original slot actor, won. */
won: boolean;
dial: number;
support: number;
}>;
export type EcazOccupyOutcomeQuote = {
destroyedArmies: string[];
fixedLosses: { owner: string; normal: number; elite: number }[];
casualties: {
owner: string;
forces: CombatForces;
dial: number;
support: number;
options: Casualties[];
} | null;
};
function requireBattle(condition: unknown, message: string): asserts condition {
if (!condition) throw new EcazOccupyBattleError(message);
}
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
/** Printed p8: ceil(E/2) free contribution and winning losses. Basic odd counts
* use this provisionally under the 8 October breadth-first instruction; the
* conflicting FAQ remains recorded, not silently promoted to a settled ruling. */
export function quoteEcazOccupyBattle(
input: EcazOccupyBattleInput,
): EcazOccupyBattleProfile {
const { advanced, ecaz, ally, lead, canceled, battleOrderActor } = input;
requireBattle(
typeof advanced === 'boolean',
'The Occupy profile needs an explicit Basic or Advanced rules band.',
);
requireBattle(
ecaz && ally && text(ecaz.id) && text(ally.id) && ecaz.id !== ally.id &&
ecaz.faction === 'ecaz' && ally.faction !== 'ecaz' &&
ecaz.ally === ally.id && ally.ally === ecaz.id,
'An Occupy battle needs Ecaz and its distinct reciprocal ally.',
);
requireBattle(
validCombatForces(ecaz.forces) && validCombatForces(ally.forces),
'An Occupy battle needs valid native physical fighter pools.',
);
requireBattle(
ecaz.noFieldPresence === undefined &&
(ally.noFieldPresence === undefined ||
ally.noFieldPresence === true && ally.faction === 'richese'),
'Only an original concealed Richese ally may supply No-Field battle presence.',
);
requireBattle(
ecaz.forces.elite === 0 && !ecaz.forces.temporaryElite &&
!ecaz.forces.normalFixedHalf && ecaz.forces.normal > 0 &&
(ally.forces.normal + ally.forces.elite > 0 || ally.noFieldPresence === true),
'Both Occupy members need storm-connected fighters; Ecaz uses ordinary counters, not advisors or an elite pool.',
);
requireBattle(
lead === ecaz.id || lead === ally.id,
'The Occupy lead must be Ecaz or its participating ally.',
);
requireBattle(
text(battleOrderActor),
'The original Occupy battle-order actor must be identified.',
);
requireBattle(
typeof canceled === 'boolean',
'The Occupy profile needs its pre-plan cancellation state.',
);
const forceOwner = canceled ? lead : ally.id;
const forces = forceOwner === ecaz.id ? ecaz.forces : ally.forces;
const fixedEcazDial = canceled ? 0 : Math.ceil(ecaz.forces.normal / 2);
return {
advanced,
battleOrderActor,
ecaz: ecaz.id,
ally: ally.id,
lead,
planOwner: lead,
payer: lead,
forceOwner,
canceled,
ecazForces: ecaz.forces,
allyForces: ally.forces,
...(ally.noFieldPresence ? { allyNoFieldPresence: true as const } : {}),
forces,
fixedEcazDial,
maxDial: fixedEcazDial + maxCombatDial(forces),
maxSupport: maxCombatSupport(forces),
};
}
/** The public dial includes Ecaz's mandatory increment. Support and typed
* casualties belong only to the native variable pool; fixed strength neither
* spends spice nor creates a CHOAM support payment. Funding is checked by the
* caller against the selected payer's existing native funding permissions. */
export function quoteEcazOccupyDial(
profile: EcazOccupyBattleProfile,
dial: number,
support: number,
): EcazOccupyDialQuote {
requireBattle(
Number.isFinite(dial) && Number.isSafeInteger(dial * 2) &&
dial >= profile.fixedEcazDial && dial <= profile.maxDial,
`The Occupy dial must include ${profile.fixedEcazDial} fixed Ecaz strength and fit the ${profile.forceOwner} fighter pool.`,
);
requireBattle(
Number.isSafeInteger(support) && support >= 0 && support <= profile.maxSupport,
`The Occupy support must fit the native ${profile.forceOwner} support rules; the fixed Ecaz contribution is free.`,
);
const variableDial = dial - profile.fixedEcazDial;
const options = casualtyOptions(profile.forces, variableDial, support);
requireBattle(
options.length > 0,
`The Occupy dial and support do not match a legal physical commitment by ${profile.forceOwner}.`,
);
return {
forceOwner: profile.forceOwner,
payer: profile.payer,
fixedEcazDial: profile.fixedEcazDial,
variableDial,
support,
options,
};
}
/** Legal physical commitments, limited by the selected lead's available
* support funding. Native payment permissions remain the caller's concern. */
export function ecazOccupyDialOptions(
profile: EcazOccupyBattleProfile,
availableSupport: number,
): EcazOccupyDialOption[] {
requireBattle(
Number.isSafeInteger(availableSupport) && availableSupport >= 0,
'Available Occupy support must be a nonnegative whole spice amount.',
);
const options: EcazOccupyDialOption[] = [];
const maximumSupport = Math.min(availableSupport, profile.maxSupport);
for (let halfDial = profile.fixedEcazDial * 2;
halfDial <= profile.maxDial * 2; halfDial++) {
const dial = halfDial / 2;
const variableDial = dial - profile.fixedEcazDial;
for (let support = 0; support <= maximumSupport; support++) {
if (casualtyOptions(profile.forces, variableDial, support).length > 0)
options.push({ dial, support, variableDial });
}
}
return options;
}
/** Force settlement only: leader deaths, cards, payments and bounty remain
* native battle outcomes. Karama's canceled winner keeps the other member's
* undialed army; canceled losing-side destruction follows the general rule. */
export function quoteEcazOccupyOutcome(
profile: EcazOccupyBattleProfile,
input: EcazOccupyOutcomeInput,
): EcazOccupyOutcomeQuote {
requireBattle(
['normal', 'traitor', 'mutualTraitors', 'explosion'].includes(input.result) &&
typeof input.won === 'boolean',
'The Occupy aftermath needs an actual native battle result and winning side.',
);
requireBattle(
!input.won || input.result === 'normal' || input.result === 'traitor',
'Mutual traitors and explosions have no Occupy winner.',
);
const dial = quoteEcazOccupyDial(profile, input.dial, input.support);
if (!input.won)
return {
destroyedArmies: [profile.ecaz, profile.ally],
fixedLosses: [],
casualties: null,
};
if (input.result === 'traitor')
return { destroyedArmies: [], fixedLosses: [], casualties: null };
return {
destroyedArmies: [],
fixedLosses: profile.canceled ? [] : [{
owner: profile.ecaz,
normal: profile.fixedEcazDial,
elite: 0,
}],
casualties: {
owner: profile.forceOwner,
forces: profile.forces,
dial: dial.variableDial,
support: dial.support,
options: dial.options,
},
};
}