Files
Dune/game/discovery-battle.ts

196 lines
6.4 KiB
TypeScript

import {
casualtyOptions,
validCombatForces,
type Casualties,
type CombatForces,
} from './combat';
import { JACURUTU_SIETCH } from './discoveries';
/** GF9 Ecaz & Moritani rulebook, p. 13. */
export const JACURUTU_BATTLE_INCOME_RULE =
'If you win a battle in Jacurutu Sietch, gain one spice for each opposing undialed force sent to the Tanks.';
export type JacurutuTankLosses = Readonly<{
normal: number;
elite: number;
}>;
/** One legal physical reading of the revealed opposing dial. */
export type JacurutuDialChoice = Readonly<{
/** The physical ordinary/elite counters the defeated player committed to the
* revealed dial, with the plan's paid support split. */
dialed: Casualties;
/** Bank spice the winner collects for the counters left undialed. */
amount: number;
}>;
/** The defeated player's pre-settlement choice between legal physical readings
* of a single revealed dial. */
export type JacurutuDialOffer = Readonly<{
event: string;
/** The defeated player, who owns the physical allocation of their own dial. */
owner: string;
winner: string;
choices: readonly JacurutuDialChoice[];
}>;
export type JacurutuBattleIncomeQuote =
| Readonly<{
kind: 'income';
winner: string;
opponent: string;
amount: number;
sentToTanks: JacurutuTankLosses;
dialed: readonly Casualties[];
}>
| Readonly<{
kind: 'allocationRequired';
winner: string;
opponent: string;
sentToTanks: JacurutuTankLosses;
choices: readonly JacurutuDialChoice[];
}>;
export type JacurutuBattleIncomeInput = Readonly<{
territory: string;
jacurutuRevealed: boolean;
winner: string | null;
opponent: string | null;
opponentForces: CombatForces | null;
opponentDial: number | null;
opponentSupport: number | null;
/** Actual opposing counters destroyed by this battle, after any future
* withdrawal or other non-Tanks movement has been removed from the pool. */
sentToTanks: JacurutuTankLosses | null;
/** The defeated player's own physical allocation for the revealed dial, once
* they have selected one. Absent while the automatic single reading, or a
* saved choice, remains pending. */
selectedDial?: Casualties | null;
}>;
export class JacurutuBattleIncomeError extends Error {
constructor(message: string) {
super(message);
this.name = 'JacurutuBattleIncomeError';
}
}
function whole(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0;
}
/**
* Quotes the automatic bank income from exact settled-battle evidence.
*
* A numeric Battle Wheel dial is not a physical counter count in Advanced
* combat. When different legal ordinary/elite dial allocations would change
* the income, the quote preserves those choices instead of inventing one. A
* supplied `selectedDial` must equal one offered reading exactly, including its
* paid support split: the same counters with a different paid distribution
* describe a different committed strength.
*/
export function quoteJacurutuBattleIncome(
input: JacurutuBattleIncomeInput,
): JacurutuBattleIncomeQuote | null {
if (
input.territory !== JACURUTU_SIETCH ||
!input.jacurutuRevealed ||
input.winner === null
)
return null;
const forces = input.opponentForces;
const sent = input.sentToTanks;
if (
!input.opponent ||
input.opponent === input.winner ||
!forces ||
!validCombatForces(forces) ||
input.opponentDial === null ||
input.opponentSupport === null ||
!sent ||
!whole(sent.normal) ||
!whole(sent.elite) ||
sent.normal > forces.normal ||
sent.elite > forces.elite
)
throw new JacurutuBattleIncomeError(
'Jacurutu income needs the opposing plan and exact forces sent to the Tanks.',
);
const eligible = casualtyOptions(
forces,
input.opponentDial,
input.opponentSupport,
);
if (!eligible.length)
throw new JacurutuBattleIncomeError(
'Jacurutu income has no physical dial allocation matching the opposing plan.',
);
const selected = input.selectedDial;
if (selected != null) {
// All four canonical fields must match one offered reading: the same
// ordinary/elite counters with the same paid support describe a different
// committed strength than a different paid distribution of those counters.
const match = eligible.find(
(choice) =>
choice.normal === selected.normal &&
choice.elite === selected.elite &&
choice.paidNormal === selected.paidNormal &&
choice.paidElite === selected.paidElite,
);
if (!match)
throw new JacurutuBattleIncomeError(
'The chosen Jacurutu dial allocation is not a legal physical allocation of the opposing revealed forces, dial and support.',
);
if (match.normal > sent.normal || match.elite > sent.elite)
throw new JacurutuBattleIncomeError(
'The chosen Jacurutu dial allocation exceeds the opposing counters actually sent to the Tanks.',
);
return {
kind: 'income',
winner: input.winner,
opponent: input.opponent,
amount: sent.normal + sent.elite - match.normal - match.elite,
sentToTanks: { ...sent },
dialed: [{ ...match }],
};
}
const dialed = eligible.filter(
(choice) => choice.normal <= sent.normal && choice.elite <= sent.elite,
);
if (!dialed.length)
throw new JacurutuBattleIncomeError(
'Jacurutu income has no physical dial allocation matching the settled Tanks losses.',
);
const choices = dialed.map((choice) => ({
dialed: { ...choice },
amount: sent.normal + sent.elite - choice.normal - choice.elite,
}));
const amounts = new Set(choices.map((choice) => choice.amount));
if (amounts.size > 1)
return {
kind: 'allocationRequired',
winner: input.winner,
opponent: input.opponent,
sentToTanks: { ...sent },
choices,
};
return {
kind: 'income',
winner: input.winner,
opponent: input.opponent,
amount: choices[0].amount,
sentToTanks: { ...sent },
dialed: choices.map((choice) => choice.dialed),
};
}
/** Resolves the canonical saved choice index to its exact physical allocation. */
export function jacurutuDialSelection(
offer: JacurutuDialOffer,
allocation: number,
): Casualties | null {
if (!Number.isSafeInteger(allocation) || allocation < 0) return null;
const choice = offer.choices[allocation];
return choice ? { ...choice.dialed } : null;
}