Files
Dune/game/combat-response-quote.ts
T
Quintine 83ccd71821
Verify and publish checkpoint container / container (push) Successful in 10m57s
Support group battle preparation powers
2026-10-28 23:42:41 +11:00

374 lines
14 KiB
TypeScript

import { FACTIONS, type FactionId } from './catalog';
import { voiceChoices } from './battle-cards';
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
import { validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice';
import { coalitionBattleBeneficiary, type CoalitionBattlePreparationContext } from './coalition-battle-preparation';
export type CombatCheckKind =
| 'kwisatz'
| 'eliteStrength'
| 'fremenSupport'
| 'choamBattleAid';
export type CombatResponseKind = CombatCheckKind | 'voice' | 'prescience';
export type CombatCheck = { kind: CombatCheckKind; owner: string };
export type CombatResponseInput = Omit<CoalitionBattlePreparationContext, 'players' | 'status' | 'advanced'> & {
ecazOccupy?: EcazOccupyBattleProfile;
status: string;
phase: number;
advanced: boolean;
turn?: number;
territoryIds: readonly string[];
players: readonly {
id: string;
faction: FactionId;
ally?: string | null;
allySinceTurn?: number;
specialKaramaUsed?: boolean;
}[];
battle: {
event?: string;
nexusBorrowedVoice?: NexusBorrowedVoiceFrame;
attacker: string;
defender: string;
territory: string;
revealed: boolean;
plans: unknown;
powerChecks?: unknown;
eliteBlocked?: unknown;
fremenSupportBlocked?: unknown;
kwisatzBlocked?: unknown;
choamAidBlocked?: unknown;
fullPlanOffered?: unknown;
preparation?: unknown;
voice?: unknown;
prescience?: unknown;
} | null;
};
export type CombatResponseOperation =
| { kind: 'next' }
| { kind: 'finishPreparation' }
| {
kind: 'response';
response: { kind: CombatResponseKind; owner: string };
canceled: boolean;
};
export type CombatResponseQuote = {
/** Undefined preserves the legacy absence of a queue. */
powerChecks: CombatCheck[] | undefined;
response: (CombatCheck & { passed: string[] }) | null;
patches: {
eliteBlocked?: string[];
fremenSupportBlocked?: boolean;
kwisatzBlocked?: boolean;
choamAidBlocked?: boolean;
deleteVoice?: true;
deletePrescience?: true;
deletePreparation?: true;
fullPlanOffered?: true;
};
decision: { kind: 'fullPlanOffer'; player: string } | null;
};
export class CombatResponseQuoteError extends Error {
constructor(message: string) {
super(message);
this.name = 'CombatResponseQuoteError';
}
}
function requireCombat(value: unknown, message: string): asserts value {
if (!value) throw new CombatResponseQuoteError(message);
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function ids(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every((id) => typeof id === 'string' && id.length > 0) &&
new Set(value).size === value.length
);
}
export function isCombatResponseKind(kind: string): kind is CombatResponseKind {
return [
'kwisatz',
'eliteStrength',
'fremenSupport',
'choamBattleAid',
'voice',
'prescience',
].includes(kind);
}
/** A finite response/preparation successor. It neither evaluates plans nor
* generates checks, reveals private information, or resolves a battle. */
export function quoteCombatResponse(
input: CombatResponseInput,
operation: CombatResponseOperation,
): CombatResponseQuote {
try {
return calculate(input, operation);
} catch (error) {
if (error instanceof CombatResponseQuoteError) throw error;
throw new CombatResponseQuoteError(
error instanceof Error ? error.message : 'Malformed combat response.',
);
}
}
function calculate(
input: CombatResponseInput,
operation: CombatResponseOperation,
): CombatResponseQuote {
const b = input.battle;
requireCombat(
input.status === 'playing' &&
input.phase === 6 &&
typeof input.advanced === 'boolean' &&
b &&
b.revealed === false,
'Combat responses require an unrevealed battle in the Battle Phase.',
);
requireCombat(
Array.isArray(input.players) &&
ids(input.players.map((p) => p.id)) &&
input.players.every((p) => FACTIONS.some((f) => f.id === p.faction)),
'Combat response seats are malformed.',
);
const combatants = [b.attacker, b.defender];
requireCombat(
ids(combatants) &&
combatants.every((id) => input.players.some((p) => p.id === id)) &&
typeof b.territory === 'string' &&
Array.isArray(input.territoryIds) &&
input.territoryIds.includes(b.territory),
'Combat responses need two distinct seated combatants and their territory.',
);
const occupy = input.ecazOccupy;
if (occupy) {
const ecaz = input.players.find(player => player.id === occupy.ecaz);
const ally = input.players.find(player => player.id === occupy.ally);
requireCombat(input.advanced === occupy.advanced && ecaz?.faction === 'ecaz' && ally && combatants.includes(occupy.lead),
'Occupy force powers need the actual Ecaz coalition and selected lead.');
const profile = quoteEcazOccupyBattle({
advanced: input.advanced,
battleOrderActor: occupy.battleOrderActor,
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: occupy.ecazForces },
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces,
...(occupy.allyNoFieldPresence ? { noFieldPresence: true as const } : {}) },
lead: occupy.lead, canceled: occupy.canceled,
});
requireCombat(profile.forceOwner === occupy.forceOwner, 'Occupy lost its actual physical force owner.');
}
requireCombat(
record(b.plans) &&
Object.keys(b.plans).every(
(id) =>
combatants.includes(id) &&
record((b.plans as Record<string, unknown>)[id]),
),
'Combat response plans are malformed.',
);
for (const flag of [
'fremenSupportBlocked',
'kwisatzBlocked',
'choamAidBlocked',
'fullPlanOffered',
] as const)
requireCombat(
b[flag] === undefined || typeof b[flag] === 'boolean',
'Combat response flags are malformed.',
);
requireCombat(
b.eliteBlocked === undefined ||
(ids(b.eliteBlocked) &&
b.eliteBlocked.every((id) => combatants.some(seat => (seat === occupy?.lead ? occupy.forceOwner : seat) === id))),
'Blocked elite owners are malformed.',
);
// This is only the possible ordering of already declared checks. Current
// elite counts or KH availability are deliberately not benefit prerequisites.
const possible: CombatCheck[] = [];
for (const id of combatants) {
const p = input.players.find(player => player.id === id)!;
const opponent = combatants.find(seat => seat !== id)!;
const other = input.players.find(player => player.id === (opponent === occupy?.lead ? occupy.forceOwner : opponent))!;
if (input.advanced && p.faction === 'atreides')
possible.push({ kind: 'kwisatz', owner: id });
const forceOwner = id === occupy?.lead ? occupy.forceOwner : id;
const forcePlayer = input.players.find(player => player.id === forceOwner)!;
if (
((input.advanced && ['emperor', 'fremen'].includes(forcePlayer.faction)) ||
(!input.advanced && forcePlayer.faction === 'ixians')) &&
!(forcePlayer.faction === 'emperor' && other.faction === 'fremen')
)
possible.push({ kind: 'eliteStrength', owner: forceOwner });
if (input.advanced && forcePlayer.faction === 'fremen')
possible.push({ kind: 'fremenSupport', owner: forceOwner });
}
const choam = input.players.find((p) => p.faction === 'choam');
if (
input.advanced &&
choam?.ally &&
combatants.includes(choam.ally) &&
input.players.find((p) => p.id === choam.ally)?.ally === choam.id
)
possible.push({ kind: 'choamBattleAid', owner: choam.id });
const rank = (value: unknown): number => {
requireCombat(
record(value) &&
typeof value.kind === 'string' &&
typeof value.owner === 'string',
'Malformed combat response check.',
);
const at = possible.findIndex(
(c) => c.kind === value.kind && c.owner === value.owner,
);
requireCombat(
at >= 0,
'Combat response check has an invalid kind, owner or faction benefit.',
);
return at;
};
requireCombat(
b.powerChecks === undefined || Array.isArray(b.powerChecks),
'Combat response queue is malformed.',
);
const raw = (b.powerChecks ?? []) as unknown[],
ranks = raw.map(rank);
requireCombat(
ranks.every((v, i) => i === 0 || v > ranks[i - 1]),
'Combat response queue is duplicated or out of order.',
);
const queue =
b.powerChecks === undefined
? undefined
: ranks.map((i) => ({ ...possible[i] }));
const result: CombatResponseQuote = {
powerChecks: queue,
response: null,
patches: {},
decision: null,
};
const next = () => {
const check = result.powerChecks?.shift();
if (check) result.response = { ...check, passed: [] };
};
const finish = () => {
result.patches.deletePreparation = true;
const atreides = input.players.find((p) => p.faction === 'atreides');
if (
input.advanced &&
atreides &&
!atreides.specialKaramaUsed &&
!b.fullPlanOffered &&
!Object.keys(b.plans as object).length
) {
result.patches.fullPlanOffered = true;
result.decision = { kind: 'fullPlanOffer', player: atreides.id };
}
};
if (operation.kind === 'next') {
next();
return result;
}
if (operation.kind === 'finishPreparation') {
finish();
return result;
}
requireCombat(
operation.kind === 'response' &&
typeof operation.canceled === 'boolean' &&
record(operation.response),
'Malformed combat response operation.',
);
const response = operation.response;
if (response.kind !== 'voice' && response.kind !== 'prescience') {
const current = rank(response);
requireCombat(
ranks.every((i) => i > current),
'The current combat response does not precede its remaining checks.',
);
if (response.kind === 'eliteStrength' && operation.canceled) {
const blocked = (b.eliteBlocked ?? []) as string[];
requireCombat(
!blocked.includes(response.owner),
'This elite response was already canceled.',
);
result.patches.eliteBlocked = [...blocked, response.owner];
} else if (response.kind === 'fremenSupport')
result.patches.fremenSupportBlocked = operation.canceled;
else if (response.kind === 'kwisatz')
result.patches.kwisatzBlocked = operation.canceled;
else if (response.kind === 'choamBattleAid')
result.patches.choamAidBlocked = operation.canceled;
next();
return result;
}
const owner = input.players.find((p) => p.id === response.owner),
faction = response.kind === 'voice' ? 'beneGesserit' : 'atreides';
const borrowed = response.kind === 'voice' && b.nexusBorrowedVoice;
if (borrowed) {
requireCombat(typeof input.turn === 'number' && typeof b.event === 'string',
'Borrowed Voice has lost its original battle occurrence.');
validateNexusBorrowedVoice({ turn: input.turn,
battle: { event: b.event, attacker: b.attacker, defender: b.defender } }, borrowed);
requireCombat(borrowed.stage === 'declared' && borrowed.owner === response.owner &&
owner && !owner.ally && combatants.includes(owner.id) &&
!input.players.some(p => p.faction === 'beneGesserit') && record(b.voice) &&
b.voice.target === borrowed.voice?.target && b.voice.kind === borrowed.voice?.kind &&
b.voice.must === borrowed.voice?.must,
'Borrowed Voice does not match the declared combatant source.');
} else requireCombat(owner?.faction === faction,
'Combat preparation response has the wrong faction owner.');
requireCombat(owner, 'Combat preparation response has no owner.');
// Match battlePreparation's existing provider selection, including its
// one-way ally link. CHOAM's queue above independently requires mutual links.
const beneficiary = coalitionBattleBeneficiary(input, owner.id, b.attacker, b.defender);
requireCombat(
beneficiary,
'Combat preparation owner has no combatant beneficiary.',
);
const opponent = combatants.find((id) => id !== beneficiary)!;
if (response.kind === 'voice') {
requireCombat(
record(b.voice) &&
b.voice.target === opponent &&
typeof b.voice.kind === 'string' &&
voiceChoices(input.advanced).includes(b.voice.kind) &&
typeof b.voice.must === 'boolean',
'The declared Voice response is malformed or targets the wrong opponent.',
);
// Voice has already advanced preparation to Prescience (or its finish).
if (b.preparation !== undefined) {
const atreides = input.players.find((p) => p.faction === 'atreides');
const recipient = atreides && coalitionBattleBeneficiary(input, atreides.id, b.attacker, b.defender);
requireCombat(
record(b.preparation) &&
b.preparation.kind === 'prescience' &&
recipient &&
b.preparation.owner === atreides!.id &&
b.preparation.beneficiary === recipient,
'The Voice response has a malformed remaining Prescience preparation.',
);
}
if (operation.canceled) result.patches.deleteVoice = true;
} else {
requireCombat(
record(b.prescience) &&
b.prescience.player === beneficiary &&
['leader', 'weapon', 'defense', 'dial'].includes(
String(b.prescience.field),
),
'The declared Prescience response is malformed or has the wrong beneficiary.',
);
requireCombat(
record(b.preparation) &&
b.preparation.kind === 'prescienceAnswer' &&
b.preparation.owner === opponent &&
b.preparation.beneficiary === beneficiary,
'Prescience needs its pending opposing answer.',
);
if (operation.canceled) {
result.patches.deletePrescience = true;
finish();
}
}
return result;
}