import { harvesterAvailable } from './cards'; export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial', 'karamaTleilaxuSpecial', 'thumper', 'amal'] as const; export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number]; export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' | 'amal' { return effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'karamaBattleCancel' || effect === 'karamaHarkonnenSpecial' || effect === 'karamaEmperorSpecial' || effect === 'karamaAtreidesSpecial' || effect === 'karamaFremenSpecial' || effect === 'karamaTleilaxuSpecial' ? 'karama' : effect; } export type KaramaHarkonnenSpecialSource = { turn: number; owner: string }; export type KaramaEmperorSpecialSource = { turn: number; owner: string }; export type KaramaTleilaxuSpecialSource = { turn: number; owner: string }; export type KaramaFremenSpecialSource = { turn: number; owner: string }; export type KaramaAtreidesSpecialSource = { turn: number; owner: string; battle: string; territory: string }; export type KaramaBattleCancelSource = { turn: number; battle: string; territory: string; kind: 'voice' | 'prescience'; owner: string; }; export type KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string }; export type KaramaShipmentSource = { turn: number; recipient: string; kind: 'original' } | { turn: number; recipient: string; kind: 'cunning'; event: string }; export type WindowCardOpportunity = { effect: Exclude; source: string; turn: number } | KaramaShipmentOpportunity; export type WindowCardGame = { status: string; advanced?: boolean; phase: number; turn: number; shieldWallDestroyed: boolean; stormCardWindowOpen?: boolean; stormResolution?: unknown; thumperWindowOpen?: boolean; amalWindowOpen?: boolean; spiceSequence?: unknown; spiceResolution?: unknown; nexus?: unknown; spiceWindow?: { event?: string; territory: string; sector: number; harvested: boolean; harvesters?: number; harvesterClosed?: boolean; } | null; response?: unknown; /** Public battle identity; plans and card custody are deliberately omitted. */ battle?: { event?: string | null; territory: string; revealed: boolean } | null; decision?: unknown; phaseOpening?: unknown; truthtrance?: unknown; /** Engine rows retain the physical array/index; public views expose only the lot. */ auction?: unknown; bidPromiseLot?: { turn: number; lot: number } | null; richeseAuction?: unknown; currentAuctionSale?: unknown; ixAuction?: unknown; active?: string | null; movementRemaining?: readonly string[] | null; players?: readonly { id: string; shipped?: boolean; faction?: string; specialKaramaUsed?: boolean }[]; karamaShipping?: { player: string } | null; nexusGuildCunningHistory?: readonly { receipt: { turn: number; owner: string; event: string }; stage: string }[]; /** Public projection contains no physical cards or private funding. */ karamaShipmentWindows?: readonly KaramaShipmentOpportunity[]; }; const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); const identifier = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value === value.trim(); const whole = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; /** Decode the public owner and turn even after the special power is spent. */ export function parseKaramaHarkonnenSpecialSource(source: unknown): KaramaHarkonnenSpecialSource | null { if (!identifier(source)) return null; let tuple: unknown; try { tuple = JSON.parse(source); } catch { return null; } if (!Array.isArray(tuple) || tuple.length !== 3 || tuple[0] !== 'karamaHarkonnenSpecial' || !whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null; return { turn: tuple[1], owner: tuple[2] }; } /** Decode the public owner and turn even after the special power is spent. */ export function parseKaramaEmperorSpecialSource(source: unknown): KaramaEmperorSpecialSource | null { if (!identifier(source)) return null; let tuple: unknown; try { tuple = JSON.parse(source); } catch { return null; } if (!Array.isArray(tuple) || tuple.length !== 3 || tuple[0] !== 'karamaEmperorSpecial' || !whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null; return { turn: tuple[1], owner: tuple[2] }; } /** Decode the public owner and turn even after the special power is spent. */ export function parseKaramaTleilaxuSpecialSource(source: unknown): KaramaTleilaxuSpecialSource | null { if (!identifier(source)) return null; let tuple: unknown; try { tuple = JSON.parse(source); } catch { return null; } if (!Array.isArray(tuple) || tuple.length !== 3 || tuple[0] !== 'karamaTleilaxuSpecial' || !whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null; return { turn: tuple[1], owner: tuple[2] }; } /** Decode the public phase window even after the special power is spent. */ export function parseKaramaFremenSpecialSource(source: unknown): KaramaFremenSpecialSource | null { if (!identifier(source)) return null; let tuple: unknown; try { tuple = JSON.parse(source); } catch { return null; } if (!Array.isArray(tuple) || tuple.length !== 3 || tuple[0] !== 'karamaFremenSpecial' || !whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null; return { turn: tuple[1], owner: tuple[2] }; } /** Decode the public battle window even after the inspection closes. */ export function parseKaramaAtreidesSpecialSource(source: unknown): KaramaAtreidesSpecialSource | null { if (!identifier(source)) return null; let tuple: unknown; try { tuple = JSON.parse(source); } catch { return null; } if (!Array.isArray(tuple) || tuple.length !== 5 || tuple[0] !== 'karamaAtreidesSpecial' || !whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || !identifier(tuple[3]) || !identifier(tuple[4]) || JSON.stringify(tuple) !== source) return null; return { turn: tuple[1], owner: tuple[2], battle: tuple[3], territory: tuple[4] }; } /** Source decoding remains available after the actual grant closes its window. */ export function parseKaramaShipmentSource(source: unknown): KaramaShipmentSource | null { if (!identifier(source)) return null; let tuple: unknown; try { tuple = JSON.parse(source); } catch { return null; } if (!Array.isArray(tuple) || tuple[0] !== 'karamaShipment' || !whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null; if (tuple.length === 4 && tuple[3] === 'original') return { turn: tuple[1], recipient: tuple[2], kind: 'original' }; return tuple.length === 5 && tuple[3] === 'cunning' && identifier(tuple[4]) ? { turn: tuple[1], recipient: tuple[2], kind: 'cunning', event: tuple[4] } : null; } /** Decode only the canonical event-bound public response identity. This stays * available after the original response closes for accepted promise receipts. */ export function parseKaramaBattleCancelSource(source: unknown): KaramaBattleCancelSource | null { if (!identifier(source)) return null; let tuple: unknown; try { tuple = JSON.parse(source); } catch { return null; } if (!Array.isArray(tuple) || tuple.length !== 6 || tuple[0] !== 'karamaBattleCancel' || !whole(tuple[1]) || tuple[1] < 1 || !identifier(tuple[2]) || !identifier(tuple[3]) || (tuple[4] !== 'voice' && tuple[4] !== 'prescience') || !identifier(tuple[5]) || JSON.stringify(tuple) !== source) return null; return { turn: tuple[1], battle: tuple[2], territory: tuple[3], kind: tuple[4], owner: tuple[5] }; } /** A battle response is public. The target check deliberately reads no hand, * card reservation, response pass or other private cancellation eligibility. */ export function karamaBattleCancelOpportunity(game: WindowCardGame, target: string, source?: string): WindowCardOpportunity | null { const window = windowCardOpportunity(game, 'karamaBattleCancel', source); const parsed = window && parseKaramaBattleCancelSource(window.source); if (!parsed || !identifier(target) || !game.players?.some(player => player.id === target) || target === parsed.owner) return null; return window; } /** Every still-unused original entitlement remains bound through other actors' * turns. A declared Cunning continuation permanently retires its original one. */ export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmentOpportunity[] { if (game.status !== 'playing' || game.phase !== 5 || !whole(game.turn) || game.turn < 1 || !Array.isArray(game.players) || !game.players.length || !game.players.every(player => object(player) && identifier(player.id)) || !Array.isArray(game.movementRemaining)) return []; const players = game.players, seated = new Set(players.map(player => player.id)); const remaining = game.movementRemaining; if (seated.size !== players.length || !remaining.every(recipient => identifier(recipient) && seated.has(recipient)) || new Set(remaining).size !== remaining.length || game.active != null && (!identifier(game.active) || !remaining.includes(game.active))) return []; const grant = game.karamaShipping; if (grant != null && (!object(grant) || !identifier(grant.player) || !seated.has(grant.player))) return []; if ('karamaShipmentWindows' in game) { if (!Array.isArray(game.karamaShipmentWindows)) return []; const sources = new Set(), recipients = new Set(), result: KaramaShipmentOpportunity[] = []; for (const window of game.karamaShipmentWindows) { if (!object(window) || Object.keys(window).sort().join(',') !== 'effect,recipient,source,turn' || window.effect !== 'karamaShipment' || window.turn !== game.turn || !identifier(window.recipient) || !remaining.includes(window.recipient) || grant?.player === window.recipient || !identifier(window.source) || sources.has(window.source) || recipients.has(window.recipient)) return []; const parsed = parseKaramaShipmentSource(window.source); if (!parsed || parsed.turn !== game.turn || parsed.recipient !== window.recipient || parsed.kind === 'cunning' && window.recipient !== game.active) return []; sources.add(window.source); recipients.add(window.recipient); result.push({ effect: 'karamaShipment', turn: game.turn, source: window.source, recipient: window.recipient }); } return result; } if (!players.every(player => typeof player.shipped === 'boolean')) return []; const history = game.nexusGuildCunningHistory; if (history !== undefined && (!Array.isArray(history) || !history.every(record => object(record) && object(record.receipt) && whole(record.receipt.turn) && record.receipt.turn > 0 && record.receipt.turn <= game.turn && identifier(record.receipt.owner) && seated.has(record.receipt.owner) && identifier(record.receipt.event) && typeof record.stage === 'string' && ['pending', 'secondShipment', 'extraMove', 'completed', 'canceled'].includes(record.stage)) || new Set(history.map(record => record.receipt.event)).size !== history.length)) return []; const current = history?.at(-1); return remaining.flatMap(recipient => { if (grant?.player === recipient) return []; const cunning = history?.some(record => record.receipt.turn === game.turn && record.receipt.owner === recipient); if (cunning) { if (!current || current.receipt.turn !== game.turn || current.receipt.owner !== recipient || current.stage !== 'secondShipment' || game.active !== recipient) return []; return [{ effect: 'karamaShipment' as const, turn: game.turn, recipient, source: JSON.stringify(['karamaShipment', game.turn, recipient, 'cunning', current.receipt.event]) }]; } return players.find(player => player.id === recipient)!.shipped ? [] : [{ effect: 'karamaShipment' as const, turn: game.turn, recipient, source: JSON.stringify(['karamaShipment', game.turn, recipient, 'original']) }]; }); } /** Original global window only. Temporary modal priority, actual card custody, * force conditions and chosen effect parameters remain native engine rules. */ function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null { if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null; if (effect === 'karamaHarkonnenSpecial') { const players = game.players; if (game.advanced !== true || game.phase !== 3 || !Array.isArray(players) || !players.length || !players.every(player => object(player) && identifier(player.id) && typeof player.faction === 'string' && (player.specialKaramaUsed === undefined || typeof player.specialKaramaUsed === 'boolean')) || new Set(players.map(player => player.id)).size !== players.length) return null; const owners = players.filter(player => player.faction === 'harkonnen'); if (owners.length !== 1 || owners[0].specialKaramaUsed === true) return null; return { effect, turn: game.turn, source: JSON.stringify([effect, game.turn, owners[0].id]) }; } if (effect === 'karamaEmperorSpecial') { const players = game.players; if (game.advanced !== true || game.phase !== 4 || !Array.isArray(players) || !players.length || !players.every(player => object(player) && identifier(player.id) && typeof player.faction === 'string' && (player.specialKaramaUsed === undefined || typeof player.specialKaramaUsed === 'boolean')) || new Set(players.map(player => player.id)).size !== players.length) return null; const owners = players.filter(player => player.faction === 'emperor'); if (owners.length !== 1 || owners[0].specialKaramaUsed === true) return null; return { effect, turn: game.turn, source: JSON.stringify([effect, game.turn, owners[0].id]) }; } if (effect === 'karamaTleilaxuSpecial') { const players = game.players; if (game.advanced !== true || game.phase !== 4 || !Array.isArray(players) || !players.length || !players.every(player => object(player) && identifier(player.id) && typeof player.faction === 'string' && (player.specialKaramaUsed === undefined || typeof player.specialKaramaUsed === 'boolean')) || new Set(players.map(player => player.id)).size !== players.length) return null; const owners = players.filter(player => player.faction === 'tleilaxu'); if (owners.length !== 1 || owners[0].specialKaramaUsed === true) return null; return { effect, turn: game.turn, source: JSON.stringify([effect, game.turn, owners[0].id]) }; } if (effect === 'karamaFremenSpecial') { const players = game.players; if (game.advanced !== true || game.phase !== 1 || !Array.isArray(players) || !players.length || !players.every(player => object(player) && identifier(player.id) && typeof player.faction === 'string' && (player.specialKaramaUsed === undefined || typeof player.specialKaramaUsed === 'boolean')) || new Set(players.map(player => player.id)).size !== players.length) return null; const owners = players.filter(player => player.faction === 'fremen'); if (owners.length !== 1 || owners[0].specialKaramaUsed === true) return null; return { effect, turn: game.turn, source: JSON.stringify([effect, game.turn, owners[0].id]) }; } if (effect === 'karamaAtreidesSpecial') { const players = game.players, battle = game.battle; if (game.advanced !== true || game.phase !== 6 || !battle || battle.revealed !== false || !identifier(battle.event) || !identifier(battle.territory) || !Array.isArray(players) || !players.length || !players.every(player => object(player) && identifier(player.id) && typeof player.faction === 'string' && (player.specialKaramaUsed === undefined || typeof player.specialKaramaUsed === 'boolean')) || new Set(players.map(player => player.id)).size !== players.length) return null; const owners = players.filter(player => player.faction === 'atreides'); if (owners.length !== 1 || owners[0].specialKaramaUsed === true) return null; return { effect, turn: game.turn, source: JSON.stringify([effect, game.turn, owners[0].id, battle.event, battle.territory]) }; } if (effect === 'karamaBattleCancel') { const battle = game.battle, response = game.response, players = game.players; if (game.advanced !== false || game.phase !== 6 || !battle || !identifier(battle.event) || !identifier(battle.territory) || battle.revealed !== false || !object(response) || (response.kind !== 'voice' && response.kind !== 'prescience') || !identifier(response.owner) || !Array.isArray(players) || !players.length || !players.every(player => object(player) && identifier(player.id)) || new Set(players.map(player => player.id)).size !== players.length || !players.some(player => player.id === response.owner)) return null; return { effect, turn: game.turn, source: JSON.stringify([ effect, game.turn, battle.event, battle.territory, response.kind, response.owner, ]) }; } if (effect === 'amal') { if (!whole(game.phase) || game.phase > 8 || game.amalWindowOpen !== undefined && typeof game.amalWindowOpen !== 'boolean' || game.amalWindowOpen === false) return null; if (game.amalWindowOpen === undefined) { const opening = game.phaseOpening; if (!object(opening) || !Array.isArray(opening.passed) || !opening.passed.every(identifier) || new Set(opening.passed).size !== opening.passed.length) return null; } return { effect, source: `phase-opening:${game.turn}:${game.phase}`, turn: game.turn }; } if (effect === 'thumper') { if (game.phase !== 1 || game.thumperWindowOpen !== undefined && typeof game.thumperWindowOpen !== 'boolean' || game.thumperWindowOpen === false || game.thumperWindowOpen === undefined && (game.spiceSequence || game.spiceResolution || game.spiceWindow || game.nexus)) return null; return { effect, source: `first-spice-reveal:${game.turn}`, turn: game.turn }; } if (effect === 'karamaAuction') { if (game.phase !== 3 || game.richeseAuction != null || game.currentAuctionSale != null || game.ixAuction != null || !object(game.auction)) return null; const auction = game.auction; let lot: number | undefined; if ('index' in auction || 'cards' in auction) { if (!whole(auction.index) || !Array.isArray(auction.cards) || auction.index >= auction.cards.length || !object(auction.cards[auction.index])) return null; lot = auction.index + 1; if (!Number.isSafeInteger(lot)) return null; } if ('bidPromiseLot' in game) { const publicLot = game.bidPromiseLot; if (!object(publicLot) || Object.keys(publicLot).sort().join(',') !== 'lot,turn' || publicLot.turn !== game.turn || !whole(publicLot.lot) || publicLot.lot < 1 || lot !== undefined && lot !== publicLot.lot) return null; lot = publicLot.lot; } if (lot === undefined) return null; return { effect, source: `ordinary-auction:${game.turn}:${lot}`, turn: game.turn }; } if (effect === 'weather' || effect === 'atomics') { if (game.phase !== 0 || game.stormResolution || game.stormCardWindowOpen === false || game.stormCardWindowOpen !== undefined && typeof game.stormCardWindowOpen !== 'boolean' || effect === 'atomics' && game.shieldWallDestroyed !== false) return null; return { effect, source: `storm:${game.turn}`, turn: game.turn }; } if (effect !== 'harvester' || game.phase !== 1) return null; const blow = game.spiceWindow; if (!blow || !identifier(blow.territory) || !whole(blow.sector) || typeof blow.harvested !== 'boolean' || blow.harvesters !== undefined && !whole(blow.harvesters) || blow.harvesterClosed !== undefined && typeof blow.harvesterClosed !== 'boolean' || !harvesterAvailable(blow)) return null; if (blow.event !== undefined && !identifier(blow.event)) return null; return { effect, source: blow.event ?? `legacy-spice-blow:${game.turn}:${blow.territory}:${blow.sector}`, turn: game.turn }; } /** New shipment questions use the current recipient. Accepted rows can instead * retain an exact original source while a different recipient is active. */ export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect, source?: string): WindowCardOpportunity | null { if (effect === 'karamaShipment') return karamaShipmentOpportunities(game).find(window => source === undefined ? window.recipient === game.active : window.source === source) ?? null; const window = globalWindowCardOpportunity(game, effect); return window && (source === undefined || window.source === source) ? window : null; }