import { familyAtomicsBlock } from './family-atomics'; import type { NoFieldDeployment } from './richese-no-field'; import { harvesterAvailable, type Card } from './cards'; import type { FactionId } from './catalog'; import { choamSaleGholaTiming } from './choam-market-ghola'; import { revivalPrevented } from './revival'; export type OrdinaryCardEffect = | 'weather' | 'atomics' | 'hajr' | 'harvester' | 'ghola'; export type OrdinaryCardPlayer = { id: string; faction: FactionId; forces: Record; advisors?: Record; /** Public effective presence only; no token identity, denomination or reserves. */ noField?: { deployed: Pick | null } | null; hand?: readonly Card[]; movesAllowed?: number; moved?: number; hajrUsed?: boolean; hajrPlays?: number; hajrAvailable?: boolean; }; /** Only public state and the current viewer's own hand are inspected. */ export type OrdinaryCardState = { status: 'lobby' | 'setup' | 'playing' | 'finished'; phase: number; advanced?: boolean; turn?: number; revivalPrevention?: { player: string; turn: number } | null; active: string | null; me: string; nexusGuildCunning?: { active: { hajrAvailable: boolean } | null } | null; truthtrance?: { stage: string } | null; phaseOpening?: { passed: readonly string[] } | null; response?: object | null; decision?: object | null; choamMarket?: { owner: string } | null; shieldWallDestroyed: boolean; stormCardWindowOpen?: boolean; storm?: number; stormPending?: number | null; stormResolution?: unknown; spiceWindow?: { harvested: boolean; harvesters?: number; harvesterClosed?: boolean; } | null; players: readonly OrdinaryCardPlayer[]; }; export type OrdinaryCardSelection = { amount: unknown }; export type OrdinaryCardAvailability = | { available: true } | { available: false; reason: string }; /** * Pure ordinary-card validation: no actions, mutations, random draws, or hidden * opponent data. A null result leaves other card effects to their own validators. * Omit selection for a timing-only preview; pass it to validate Weather's dial. * This describes an ordinary {type:'card',card,...} activation, without a mode. */ export function ordinaryCardAvailability( state: OrdinaryCardState, cardId: string, selection?: OrdinaryCardSelection, ): OrdinaryCardAvailability | null { const blocked = (reason: string): OrdinaryCardAvailability => ({ available: false, reason, }); const own = state.players.find((p) => p.id === state.me); if (!own) return blocked('You are not seated at this table.'); const card = own.hand?.find((c) => c.id === cardId); if ( card && !['weather', 'atomics', 'hajr', 'harvester', 'ghola'].includes( card.effect ?? '', ) ) return null; // Match the enclosing ordinary-card dispatcher gates in their existing order. if (state.status === 'finished') return blocked('This game has ended.'); if (state.truthtrance) return blocked( state.truthtrance.stage === 'priority' ? 'Declare Truthtrance or pass its priority window first.' : 'Waiting for the questioned player to answer Truthtrance.', ); if (state.phaseOpening) { if (state.status !== 'playing') return blocked('Phase opening requires an active game.'); if (state.phaseOpening.passed.includes(state.me)) return blocked('You have already passed the phase opening.'); return blocked( 'Play Amal or pass the phase opening before taking other actions.', ); } if (state.response && !(card?.effect === 'ghola' && choamSaleGholaTiming(state))) return blocked('Resolve the Karama response window first.'); if (state.decision) return blocked('Waiting for the player with the pending decision.'); if (state.status === 'lobby') return blocked('That action is not available in the lobby.'); if (state.status === 'setup') return blocked('Complete the starting choices.'); if (!card || card.kind !== 'special') return blocked('Select a special card in your hand.'); switch (card.effect as OrdinaryCardEffect) { case 'ghola': if (revivalPrevented({ phase: state.phase, turn: state.turn ?? 0, revivalPrevention: state.revivalPrevention ?? undefined }, own.id)) return blocked('Tleilaxu prevented this player’s revival, including Ghola, for the remaining Revival Phase.'); break; case 'hajr': if ( state.phase !== 5 || state.active !== state.me || (state.nexusGuildCunning?.active ? !state.nexusGuildCunning.active.hajrAvailable : own.hajrAvailable !== undefined ? !own.hajrAvailable : own.hajrUsed === undefined ? own.movesAllowed !== 1 : own.hajrUsed || (own.moved ?? 0) > (own.movesAllowed ?? 1)) ) return blocked('Play a held Hajr during your movement; Advanced requires a fresh immediate opportunity after a completed Force Movement.'); break; case 'harvester': if (state.phase !== 1 || !harvesterAvailable(state.spiceWindow)) return blocked( 'Play Harvester immediately after a spice blow, before it is accepted.', ); break; case 'weather': if (state.phase !== 0 || state.stormCardWindowOpen === false || state.stormResolution) return blocked('Play Weather Control before the storm moves.'); if ( selection && (typeof selection.amount !== 'number' || !Number.isSafeInteger(selection.amount) || selection.amount < 0 || selection.amount > 10) ) return blocked('Storm movement must be an integer from 0 to 10.'); break; case 'atomics': { const reason = familyAtomicsBlock(state, own); if (reason) return blocked(reason); } break; } return { available: true }; }