262 lines
8.8 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|