Files
Dune/game/ix-substitution-cancellation.ts
T

138 lines
4.5 KiB
TypeScript

import type { Game, ResponseWindow } from './engine';
import {
validateBattleCleanupContext,
type BattleCleanupContextQuote,
} from './karama-battle-preflight';
export class IxSubstitutionCancellationError extends Error {}
type Context = Pick<
Game,
| 'status'
| 'phase'
| 'turn'
| 'battle'
| 'lastBattle'
| 'lastBattleContext'
| 'pendingIxSubstitution'
> & {
players: readonly Pick<Game['players'][number], 'id' | 'faction' | 'hand'>[];
territories: readonly { id: string; sectors: readonly number[] }[];
combatLocations?: readonly { id: string; kind: 'territory' | 'homeworld' }[];
physicalCards: readonly { id: string }[];
};
function requireIx(value: unknown, message: string): asserts value {
if (!value) throw new IxSubstitutionCancellationError(message);
}
const record = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === 'object' && !Array.isArray(v);
const id = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
/** A denied exchange does not kill suboids or recover cyborgs. Only its saved
* declaration and the winner's actual next cleanup are prerequisites. */
export function quoteIxSubstitutionCancellation(
g: Context,
response: ResponseWindow,
) {
if (response.kind !== 'ixSubstitution') return null;
const p = g.pendingIxSubstitution;
requireIx(
g.status === 'playing' && record(p) && p.player === response.owner,
'This canceled Ixian substitution has no current declaration.',
);
const owner = g.players.find((player) => player.id === p.player);
const winner = g.players.find((player) => player.id === (p.winner ?? p.player));
requireIx(
owner?.faction === 'ixians' && winner,
'The canceled substitution needs its actual Ixian force owner and selected winner.',
);
const homeworld = g.combatLocations?.find(
(location) => location.id === p.territory && location.kind === 'homeworld',
);
const territory = g.territories.find(
(location) => location.id === p.territory,
);
requireIx(
homeworld
? p.territory.startsWith('homeworld:') && !territory
: !!territory && !p.territory.startsWith('homeworld:'),
'The canceled substitution needs a current canonical combat location.',
);
let context: BattleCleanupContextQuote;
try {
context = validateBattleCleanupContext({
phase: g.phase,
turn: g.turn,
battlePresent: !!g.battle,
lastBattle: g.lastBattle,
playerIds: g.players.map((p) => p.id),
territoryIds: [
...g.territories.map((t) => t.id),
...(homeworld ? [homeworld.id] : []),
],
territory: p.territory,
winner: winner.id,
context: g.lastBattleContext,
});
} catch (error) {
throw new IxSubstitutionCancellationError(
error instanceof Error
? error.message
: 'Invalid substitution battle receipt.',
);
}
const counts = (value: unknown): value is Record<string, number> =>
record(value) &&
(!homeworld || Object.keys(value).length === 1) &&
Object.entries(value).every(
([key, count]) =>
(homeworld
? key === homeworld.id
: territory!.sectors.some(
(sector) => key === `${territory!.id}:${sector}`,
)) &&
Number.isSafeInteger(count) &&
Number(count) > 0,
);
requireIx(
counts(p.losses) && counts(p.sources) && counts(p.recover),
'The canceled substitution needs its original casualty and exchange locations.',
);
const total = (value: Record<string, number>) =>
Object.values(value).reduce((a, b) => a + b, 0);
const sources = total(p.sources),
recover = total(p.recover);
requireIx(
Number.isSafeInteger(sources) &&
sources > 0 &&
sources === recover &&
Object.entries(p.recover).every(
([key, count]) => count <= (p.losses[key] ?? 0),
),
'The canceled substitution must retain its declared matching exchange.',
);
requireIx(
Array.isArray(p.cards) &&
p.cards.every(id) &&
new Set(p.cards).size === p.cards.length &&
p.cards.every(
(card) =>
winner.hand.filter((c) => c.id === card).length === 1 &&
g.physicalCards.filter((c) => c.id === card).length === 1,
),
'The winning battle cards must remain available for cleanup.',
);
return {
player: owner.id,
territory: p.territory,
context,
decision: p.cards.length
? {
kind: 'battleCards' as const,
player: winner.id,
territory: p.territory,
cards: [...p.cards],
}
: null,
};
}