126 lines
8.0 KiB
TypeScript
126 lines
8.0 KiB
TypeScript
import type { Game } from './engine';
|
|
import { validateFremenRevivalGrant } from './fremen-revival-grant';
|
|
|
|
const factions = new Set([
|
|
'atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen',
|
|
'richese', 'choam', 'ixians', 'tleilaxu',
|
|
]);
|
|
|
|
/** 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') ||
|
|
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.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;
|
|
|
|
// Each pending field is an active child/continuation, not a completed
|
|
// receipt. Empty pending arrays do not represent a live child.
|
|
if (Object.entries(g).some(([key, value]) => key.startsWith('pending') &&
|
|
(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 (g.response) {
|
|
// Only independent, un-nested native responses with a stable original
|
|
// source are admitted. In particular, 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 === '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;
|
|
}
|