import type { Game } from './engine'; import { validateFremenRevivalGrant } from './fremen-revival-grant'; import { richeseRevealForeignMovementPending } from './richese-reveal-foreign-movement'; import { eliteRevivalRemaining, paidForceRevivalCost, revivalPrevented } from './revival'; const factions = new Set([ 'atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen', 'richese', 'choam', 'ixians', 'tleilaxu', 'ecaz', 'moritani', ]); /** A deliberately bounded admission for an existing before-Battle parent. * This does not authorize a seat, marker or event, and does not mutate the * game. The normal No-Field action and view own those checks. */ export function richeseRevealInterruptSupported(g: Game): boolean { if (g.status !== 'playing' || g.advanced || !Number.isSafeInteger(g.phase) || g.phase < 0 || g.phase >= 6 || !Number.isSafeInteger(g.turn) || g.turn < 1 || !Array.isArray(g.expansions) || !g.expansions.includes('choam') || g.expansions.some(deck => deck !== 'choam' && deck !== 'ix' && deck !== 'ecaz') || new Set(g.expansions).size !== g.expansions.length || !Array.isArray(g.players) || !g.players.every(player => factions.has(player.faction)) || g.players.some(player => (player.faction === 'ixians' || player.faction === 'tleilaxu') && !g.expansions.includes('ix')) || g.players.some(player => (player.faction === 'ecaz' || player.faction === 'moritani') && !g.expansions.includes('ecaz')) || g.coalitionState || g.coalitionPreview || g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards || g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker || g.sandtrout || g.ecazTreachery || g.truthtrance || g.biddingEnd || g.battle || g.stormResolution || g.spiceResolution || g.spiceSequence || g.victoryContinuation || g.currentAuctionSale || g.richeseAllyOffer || g.bureaucratPayments?.pending) return false; // A mixed Truthtrance answer can create a private residual only for some // hidden facts. Fence the whole public turn's history uniformly before // examining those ledgers, so this projected availability cannot reveal // which private branch was required. // Historical saves without a complete public history retain their existing // interruption behavior; fresh games initialize an explicit empty history. if (!Array.isArray(g.truthHistory) || g.truthHistory.some(entry => entry.turn === g.turn)) return false; // An ordinary Guild reserve shipment is independent of Richese's reserves // and marker when its destination is in a different territory. Its exact // rate response remains open while the marker is materialized. const shipment = g.pendingShipment; const richese = g.players.find(player => player.faction === 'richese'); const marker = richese?.noField?.deployed?.location; const guildRate = g.response?.kind === 'guildRate' && g.phase === 5 && !!shipment && !!richese && !!marker && 'territory' in marker && shipment.player === g.response.owner && shipment.player === g.active && g.players.some(player => player.id === shipment.player && player.faction === 'guild') && shipment.turn === g.turn && shipment.territory !== marker.territory && typeof shipment.guildRateEvent === 'string' && shipment.guildRateEvent.length > 0 && shipment.guildRateEvent === g.response.guildRateEvent && !shipment.source && !shipment.noField && !shipment.alliedNoField && !shipment.richesePair && !shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent && !shipment.homeworldSources && !shipment.smuggler && !shipment.smugglerCompanion; // Each other pending field is an active child/continuation, not a completed // receipt. Empty pending arrays do not represent a live child. const foreignMovement = richeseRevealForeignMovementPending(g); if (Object.entries(g).some(([key, value]) => key.startsWith('pending') && !(key === 'pendingShipment' && guildRate) && key !== foreignMovement && (Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return false; const live = (rows: readonly {turn: number; fulfilled?: boolean; released?: boolean}[] | undefined) => !!rows?.some(row => row.turn === g.turn && !row.fulfilled && !row.released); if (live(g.charityPromises) || live(g.travelPromises) || live(g.turnActionPromises) || live(g.shipmentPromises) || live(g.bidPromises) || live(g.phaseBidPromises) || live(g.richeseBidPromises) || live(g.earlyRicheseBidPromises) || live(g.revivalPromises) || live(g.leaderRevivalPromises) || live(g.revivalExpressionPromises) || live(g.bribePromises) || live(g.pledgePromises) || live(g.bribeExpressionPromises) || live(g.alliancePromises) || live(g.allianceExpressionPromises) || live(g.movementPromises) || live(g.movementExpressionPromises) || g.futureBattleLeaderPromises?.some(row => row.turn === g.turn && (row.status === 'waiting' || row.status === 'bound')) || g.deals?.some(deal => deal.status === 'active')) return false; // Mixed-question receipts can own binding state separately from the ordinary // residual lists. Conservatively retain their whole current-turn boundary. const mixed = [g.mixedBidQuestions, g.mixedRevivalQuestions, g.mixedBribeQuestions, g.mixedAllianceQuestions, g.mixedMovementQuestions, g.mixedTraitorQuestions, g.mixedBattleQuestions]; if (mixed.some(rows => rows?.some(row => row.turn === g.turn && row.binding))) return false; if (foreignMovement) return true; if (g.response) { // Only independent, un-nested native responses with a stable original // source are admitted. Other shipment and revival transactions still // need their own continuation proofs. const response = g.response; if (g.decision || g.phaseOpening || g.choamMarket || !Array.isArray(response.passed) || new Set(response.passed).size !== response.passed.length || response.passed.some(id => !g.players.some(player => player.id === id))) return false; const owner = g.players.find(player => player.id === response.owner); if (!owner) return false; const keys = Object.keys(response).sort().join(','); if (response.kind === 'guildRate') return !!guildRate && keys === 'guildRateEvent,intent,kind,owner,passed'; if (response.kind === 'atreidesSpice') return keys === 'kind,owner,passed' && g.phase === 5 && owner.faction === 'atreides' && !g.players.some(player => player.faction === 'beneGesserit') && Array.isArray(g.spiceDeck) && g.spiceDeck.length > 0 && g.spicePeekKnown === false && Array.isArray(g.movementRemaining) && g.movementRemaining.length > 0; if (response.kind === 'emperorGift') { const recipient = g.players.find(player => player.id === response.recipient); return keys === 'amount,elite,kind,owner,passed,recipient' && owner.faction === 'emperor' && !!recipient && recipient.id !== owner.id && owner.ally === recipient.id && recipient.ally === owner.id && Number.isSafeInteger(response.amount) && response.amount! > 0 && response.amount! <= owner.spice && response.elite === 0; } if (response.kind === 'emperorRevival') { const recipient = g.players.find(player => player.id === response.recipient); const amount = response.amount, elite = response.elite; return keys === 'amount,elite,kind,owner,passed,recipient' && g.phase === 4 && owner.faction === 'emperor' && !!recipient && recipient.id !== owner.id && owner.ally === recipient.id && recipient.ally === owner.id && Number.isSafeInteger(amount) && amount! > 0 && amount! <= recipient.tanks && Number.isSafeInteger(elite) && elite! >= 0 && elite! <= amount! && elite! <= (recipient.elites?.tanks ?? 0) && amount! - elite! <= recipient.tanks - (recipient.elites?.tanks ?? 0) && elite! <= eliteRevivalRemaining(recipient, g.advanced) && Number.isSafeInteger(g.emperorExtra[recipient.id] ?? 0) && (g.emperorExtra[recipient.id] ?? 0) >= 0 && (g.emperorExtra[recipient.id] ?? 0) + amount! <= 3 && !revivalPrevented(g, recipient.id) && owner.spice >= paidForceRevivalCost(recipient, amount!, elite!); } if (response.kind === 'revivalIncome') return keys === 'amount,kind,owner,passed,recipient' && owner.faction === 'tleilaxu' && g.players.some(player => player.id === response.recipient) && Number.isSafeInteger(response.amount) && response.amount! > 0; if (response.kind === 'choamCharity') return keys === 'kind,owner,passed' && g.phase === 2 && owner.faction === 'choam' && g.choamCharity?.turn !== g.turn; if (response.kind === 'atreidesAuction') { if (keys !== 'kind,owner,passed' || g.phase !== 3 || owner.faction !== 'atreides') return false; return !g.richeseAuction && !!g.auction && Array.isArray(g.auction.cards) && Number.isSafeInteger(g.auction.index) && g.auction.index >= 0 && !!g.auction.cards[g.auction.index]; } if (response.kind === 'richeseAuction') { const round = g.richeseBidding; return keys === 'kind,owner,passed' && g.phase === 3 && owner.faction === 'richese' && !!round && round.turn === g.turn && round.owner === response.owner && round.stage === 'declaration' && (round.position === 'first' || round.position === 'last') && Number.isSafeInteger(round.normalCount) && round.normalCount! >= 0 && round.normalCount! <= g.players.length && !round.cacheCanceled && !g.auction && !g.richeseAuction && !g.ixAuction && Array.isArray(g.richeseCache) && (round.normalCountBeforeCache === undefined || Number.isSafeInteger(round.normalCountBeforeCache) && round.normalCountBeforeCache >= round.normalCount! && round.normalCountBeforeCache <= g.players.length && round.normalCountBeforeCache - round.normalCount! <= 1); } if (response.kind === 'fremenRevivalGrant') { if (keys !== 'fremenGrant,kind,owner,passed,recipient' || g.phase !== 4 || owner.faction !== 'fremen' || !response.fremenGrant || response.fremenGrant.owner !== response.owner || response.fremenGrant.recipient !== response.recipient) return false; try { validateFremenRevivalGrant(g, response.fremenGrant); return true; } catch { return false; } } return false; } if (g.phaseOpening) return g.expansions.includes('ix') && !!g.amalWindowOpen && !g.decision && !g.choamMarket; const decision = g.decision; if (!decision) return false; if (decision.kind === 'auctionPayment') return g.phase === 3 && !!g.auction && g.auction.bidder === decision.player && !g.richeseAuction && !g.choamMarket; if (decision.kind === 'richeseDeclaration' || decision.kind === 'richeseCacheTerms' || decision.kind === 'richeseCache' || decision.kind === 'richeseUnbid') { const round = g.richeseBidding; if (g.phase !== 3 || !round || round.turn !== g.turn || round.owner !== decision.player || g.choamMarket) return false; if (decision.kind === 'richeseDeclaration') return round.stage === 'declaration' && !g.richeseAuction; if (decision.kind === 'richeseUnbid') return round.stage === 'lot' && g.richeseAuction?.source === 'cache' && g.richeseAuction.outcome?.kind === 'unbid'; return round.stage === 'cacheOffer' && !g.richeseAuction; } if (decision.kind === 'choamMarket' || decision.kind === 'choamTradeReply' || decision.kind === 'choamTradeConfirm') { const market = g.choamMarket; if (!market || market.sale) return false; if (decision.kind === 'choamMarket') return market.owner === decision.player && !market.trade; if (!market.trade) return false; return decision.kind === 'choamTradeReply' ? market.trade.ally === decision.player && !market.trade.returned : market.owner === decision.player && !!market.trade.returned; } return decision.kind === 'richeseAllyOpportunity' && g.phase === 5 && g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === decision.recipient; }