import { fighterCount } from './advisors'; import type { Game } from './engine'; import { isDiscoveryTokenId, type DiscoveryOpaqueTokenId } from './discoveries'; export class DiscoveryStormError extends Error {} const whole = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) >= 0; const record = (x: unknown): x is Record => !!x && typeof x === 'object' && !Array.isArray(x); function need(x: unknown, message: string): asserts x { if (!x) throw new DiscoveryStormError(message); } export type StormMovementSource = { turn: number; kind: 'dials' | 'card' | 'weather'; distance: number; signature: string; }; export type DiscoveryStorm = { event: string; turn: number; owner: string; token: DiscoveryOpaqueTokenId; from: number; base: number; delta: -1 | 0 | 1 | null; stage: 'choose' | 'traversal' | 'complete'; signature: string; queue?: { order: string[]; choices: { owner: string; delta: -1 | 0 | 1 }[]; }; }; export type DiscoveryStormContext = Pick< Game, | 'status' | 'turn' | 'phase' | 'storm' | 'stormPending' | 'discoveryEnabled' | 'discoveries' | 'players' | 'order' > & { stormMovementSource?: StormMovementSource }; export function stormSourceSignature( source: Omit, ) { return JSON.stringify([source.turn, source.kind, source.distance]); } export function createStormSource( turn: number, kind: StormMovementSource['kind'], distance: number, ): StormMovementSource { const source = { turn, kind, distance, signature: '' }; source.signature = stormSourceSignature(source); validateStormSource(source); return source; } export function validateStormSource(source: StormMovementSource) { need( record(source) && Object.keys(source).sort().join(',') === 'distance,kind,signature,turn' && whole(source.turn) && source.turn > 0 && ['dials', 'card', 'weather'].includes(source.kind) && whole(source.distance) && source.distance <= 40 && source.signature === stormSourceSignature(source), 'The storm has lost its original movement source.', ); } export function discoveryStormSignature( frame: Omit, ) { const values: unknown[] = [ frame.event, frame.turn, frame.owner, frame.token, frame.from, frame.base, frame.delta, frame.stage, ]; if (frame.queue) values.push(frame.queue.order, frame.queue.choices); return JSON.stringify(values); } export function discoveryStormDistance(frame: DiscoveryStorm): number { return frame.base + (frame.queue ? frame.queue.choices.reduce((sum, choice) => sum + choice.delta, 0) : frame.delta ?? 0); } function stationOccupants(g: DiscoveryStormContext): string[] { return g.order.filter((id) => { const player = g.players.find((p) => p.id === id); return player && fighterCount(player, 'ecological-testing-station') > 0; }); } export function discoveryStormOffer(g: DiscoveryStormContext, frame?: DiscoveryStorm) { const token = g.discoveryEnabled && g.discoveries?.tokens.find( (t) => t.face === 'ecological-testing-station' && t.status === 'placed' && t.revealedTurn !== null, ); if (!token) return null; const occupants = stationOccupants(g); if (!occupants.length || occupants.length > 2) return null; if (frame) validateDiscoveryStorm(g, frame); const owner = frame?.owner ?? occupants[0], source = g.stormMovementSource; if (source) validateStormSource(source); let blocked: string | null = null; if (g.status !== 'playing' || g.phase !== 0 || g.stormPending === null) blocked = 'Wait until ordinary storm movement is revealed.'; else if (!source || source.turn !== g.turn) blocked = 'This storm was already pending before the station became available. Its original movement is preserved.'; else if (source.kind === 'weather') blocked = 'Ecological Testing Station cannot change Weather Control.'; else if (source.distance !== (frame?.base ?? g.stormPending)) blocked = 'The current storm movement no longer matches this station opportunity.'; const base = frame ? discoveryStormDistance(frame) : g.stormPending ?? 0; const options = ([-1, 0, 1] as const) .filter((delta) => base + delta >= 0 && base + delta <= 40) .map((delta) => ({ delta, distance: base + delta })); return { owner, token: token.id, base, options, blocked }; } export function createDiscoveryStorm( g: DiscoveryStormContext, event: string, ): DiscoveryStorm { const offer = discoveryStormOffer(g); need( offer && !offer.blocked && typeof event === 'string' && event.length > 0, offer?.blocked ?? 'No station occupant may adjust this storm.', ); const frame: DiscoveryStorm = { event, turn: g.turn, owner: offer.owner, token: offer.token, from: g.storm, base: offer.base, delta: null, stage: 'choose', signature: '', }; const occupants = stationOccupants(g); if (occupants.length === 2) frame.queue = { order: occupants, choices: [] }; frame.signature = discoveryStormSignature(frame); validateDiscoveryStorm(g, frame); return frame; } export function validateDiscoveryStorm( g: DiscoveryStormContext, frame: DiscoveryStorm, ) { need( record(frame) && Object.keys(frame).sort().join(',') === (frame.queue ? 'base,delta,event,from,owner,queue,signature,stage,token,turn' : 'base,delta,event,from,owner,signature,stage,token,turn') && typeof frame.event === 'string' && frame.event.length > 0 && typeof frame.owner === 'string' && g.players.some((p) => p.id === frame.owner) && isDiscoveryTokenId(frame.token) && whole(frame.turn) && frame.turn > 0 && frame.turn <= g.turn && whole(frame.from) && frame.from <= 18 && whole(frame.base) && frame.base <= 40 && ['choose', 'traversal', 'complete'].includes(frame.stage) && (frame.stage === 'choose' ? frame.delta === null : [-1, 0, 1].includes(frame.delta as number)), 'The station has lost its signed storm choice.', ); if (frame.queue) { const queue = frame.queue; need( record(queue) && Object.keys(queue).sort().join(',') === 'choices,order' && Array.isArray(queue.order) && queue.order.length === 2 && new Set(queue.order).size === 2 && queue.order.every((id) => typeof id === 'string' && g.players.some((p) => p.id === id)) && Array.isArray(queue.choices) && queue.choices.length <= 2 && queue.choices.every((choice, index) => record(choice) && Object.keys(choice).sort().join(',') === 'delta,owner' && choice.owner === queue.order[index] && [-1, 0, 1].includes(choice.delta)) && frame.owner === queue.order[Math.min(queue.choices.length, 1)] && (frame.stage === 'choose' ? queue.choices.length < 2 : queue.choices.length === 2 && frame.delta === queue.choices[1].delta), 'The station must retain exactly one ordered choice per original occupant.', ); let distance = frame.base; for (const choice of queue.choices) { distance += choice.delta; need(distance >= 0 && distance <= 40, 'Every station choice must retain a legal current distance.'); } } need( discoveryStormDistance(frame) >= 0 && discoveryStormDistance(frame) <= 40 && frame.signature === discoveryStormSignature(frame), 'The station has lost its signed storm choice.', ); need( g.discoveryEnabled && g.discoveries?.tokens.some( (token) => token.id === frame.token && token.face === 'ecological-testing-station' && token.status === 'placed' && token.revealedTurn !== null && token.revealedTurn <= frame.turn, ), 'The station choice must retain its original revealed physical token.', ); if (frame.stage === 'complete') return; need( g.discoveryEnabled && g.status === 'playing' && g.phase === 0 && g.turn === frame.turn && g.storm === frame.from && g.stormMovementSource, 'Finish the station choice with its original storm.', ); validateStormSource(g.stormMovementSource); need( g.stormMovementSource.turn === frame.turn && g.stormMovementSource.kind !== 'weather' && g.stormMovementSource.distance === frame.base, 'The station cannot replace its original storm source or affect Weather Control.', ); if (frame.stage === 'choose') { const occupants = stationOccupants(g); need( JSON.stringify(occupants) === JSON.stringify(frame.queue?.order ?? [frame.owner]) && g.stormPending === discoveryStormDistance(frame), 'The station choice no longer belongs to its original occupant and storm.', ); } else need( g.stormPending === discoveryStormDistance(frame), 'The adjusted storm must retain the chosen distance.', ); } export function chooseDiscoveryStorm( g: DiscoveryStormContext, frame: DiscoveryStorm, owner: string, event: unknown, delta: unknown, ): DiscoveryStorm { validateDiscoveryStorm(g, frame); need( frame.stage === 'choose' && frame.owner === owner && frame.event === event && typeof delta === 'number' && [-1, 0, 1].includes(delta) && discoveryStormDistance(frame) + delta >= 0 && discoveryStormDistance(frame) + delta <= 40, 'Choose the current station opportunity: decrease one, keep the distance, or increase one.', ); const choices = frame.queue ? [...frame.queue.choices, { owner, delta: delta as -1 | 0 | 1 }] : null; const more = choices !== null && choices.length < frame.queue!.order.length; const next: DiscoveryStorm = { ...frame, owner: more ? frame.queue!.order[choices!.length] : frame.owner, event: more ? `${frame.event}:1` : frame.event, delta: more ? null : delta as -1 | 0 | 1, stage: more ? 'choose' : 'traversal', ...(choices ? { queue: { order: [...frame.queue!.order], choices } } : {}), signature: '', }; next.signature = discoveryStormSignature(next); return next; } export function finishDiscoveryStorm(frame: DiscoveryStorm): DiscoveryStorm { need( frame.stage === 'traversal', 'Only a committed station adjustment can complete.', ); const next = { ...frame, stage: 'complete' as const, signature: '' }; next.signature = discoveryStormSignature(next); return next; }