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[]; 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 => !!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 => 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) => 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, }; }