374 lines
14 KiB
TypeScript
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;
|
|
}
|