import { basicExpansionLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { isStormCardDistance, type StormCardComponent } from './storm-cards'; import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills'; import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards'; import { bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment'; import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question'; import { canUsePlanetologistBattleSpecial, type BattleLeaderSkill, } from './leader-skill-combat'; import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate'; import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board'; import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer'; import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement'; import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field'; import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment'; import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle'; import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement'; import { sandmasterWormCollection } from './sandmaster-worm'; import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker'; import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense'; import { diplomatRetreatChoices, type DiplomatRetreatSelection, type DiplomatRetreatDestination } from './diplomat-retreat'; import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup'; import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair } from './battle-card-slots'; import { HARASS_WITHDRAW_CARD, HarassWithdrawError, isHarassWithdraw, quoteHarassWithdraw, defaultHarassWithdrawAllocation, harassWithdrawNeedsAllocation, type HarassWithdrawSelection, type HarassWithdrawContext, type HarassWithdrawPreview } from './harass-withdraw'; import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions'; import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm'; import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight'; import { advanceDiscoveryEntry, beginDiscoveryEntryArrival, createDiscoveryEntryRound, finishDiscoveryEntryArrival, quoteDiscoveryEntry, validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound, type DiscoveryEntryArrivalChild, type DiscoveryEntryRound, type DiscoveryEntrySelection } from './discovery-entry'; import { createDiscoveryState, validateDiscoveryState, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken, isDiscoverySpiceCardId, isDiscoveryTokenId, DISCOVERY_SPICE_CARDS, DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryState } from './discoveries'; import { quoteJacurutuBattleIncome } from './discovery-battle'; import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker'; import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade'; import { quoteNexusChoamInspection, sampleNexusChoamInspection, nexusChoamInspectionSignature, type NexusChoamInspectionReceipt } from './nexus-choam-inspection'; import { quoteNexusMoritaniRetention, nexusMoritaniRetentionSignature, type NexusMoritaniRetentionReceipt } from './nexus-moritani-retention'; import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal'; import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySignature, validateEcazInquiry, type EcazInquiryReceipt } from './nexus-ecaz-inquiry'; import { nexusCleanPlayBlocked } from './nexus-play-boundary'; import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally'; import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase'; import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival'; import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning'; import { truthKnowledgeOf } from './truthtrance-knowledge'; import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate'; import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally'; import { shipmentAvailable } from './shipment-opportunity'; import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning'; import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese'; import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections'; import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTraitorExchange, validateNexusTraitorHistory, validateNexusTraitorSnapshot, type NexusTraitorExchange, type NexusTraitorSnapshot } from './nexus-traitor-exchange'; import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFaceDancerReceipt } from './nexus-face-dancers'; import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids'; import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors'; import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar'; import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani'; import { moritaniBetrayalOffer } from './nexus-moritani-betrayal'; import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal'; import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal, type StoredFremenNexusBetrayal } from './nexus-fremen-betrayal'; import { atomicsAllianceChangeBlocked, atomicsAllianceStatus, atomicsShipmentBlocked, quoteMoritaniAtomics, type AtomicsAftermath, type AtomicsQuote } from './moritani-atomics'; import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location'; import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam'; import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations'; import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards'; import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase'; import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history'; import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence'; import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer'; import { createTupileIntelligenceState, appendTupileIntelligenceObservation, validateTupileIntelligenceState, type TupileIntelligenceState } from './tupile-intelligence-state'; import { quoteHomeworldCustody } from './homeworld-custody'; import { quoteHomeworldShipment, type HomeworldShipmentIntent } from './homeworld-shipment'; import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment'; import { quoteJunctionTransport } from './junction-transport'; import { homeworldMovementForesightBlock, homeworldSpiritualAdvisorLimit, homeworldMobileStrongholdMovementBlock, homeworldNoFieldMovementBlock } from './homeworld-mobility'; import { currentJunctionOffer, junctionOfferEvent, junctionOfferIntegrity, junctionSponsor, type JunctionOffer } from './junction-offer'; import { homeworldAllianceBlock } from './homeworld-alliance'; import { quoteNexusAlliance } from './nexus-alliance'; import { quoteHomeworldSubstitution } from './homeworld-substitution'; import { combatArmy, combatLocations, combatLocationName, homeworldBattleLocation, quoteCombatBoard, quoteCombatBoardContinuation } from './combat-location'; import { battleChooserEvent, quoteBattleChoosers, reorderBattleChoosers, validateBattleChooserOrder, BattleChooserOrderError, type BattleChooserOrder } from './battle-chooser-order'; import { battleTieOwner, changeSaphoAggressor, saphoAggressorEvent, validateSaphoAggressor, SaphoAggressorError, type SaphoAggressorState } from './sapho-aggressor'; import { quoteHomeworldCombatLoss } from './homeworld-combat-loss'; import { quoteHomeworldBattleRules } from './homeworld-battle-rules'; import type { HomeworldForces } from './homeworld-custody'; import { nativeReserveSources } from './homeworld-options'; import { HOMEWORLD_CARDS } from './homeworld-cards'; import { lowGrummanRevealBlock } from './homeworld-collection'; import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection'; import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return'; import { ambassadorPhaseAllowed } from './ambassador-phase'; import { makeHomeworldVictoryReturn, validateHomeworldVictoryReturn, homeworldVictoryOfferSignature, homeworldVictoryObligationSignature, type HomeworldVictoryReturn, type HomeworldVictoryObligation } from './homeworld-victory-return'; import { quoteHomeworldVictoryReinforcement, quoteHomeworldVictoryReinforcementDestination } from './homeworld-victory-reinforcement'; import { homeworldArrivalSignature, appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, validateHomeworldArrival } from './homeworld-arrival'; import { quoteHomeworldRevivalDeployment, type HomeworldRevivalDeploymentSource, type HomeworldRevivalDeploymentGroup, } from './homeworld-revival-deployment'; import { makeHomeworldRevivalReturn, makeHomeworldRevivalProgress, type HomeworldRevivalProgress, validateHomeworldRevivalReturn, homeworldRevivalResumeSignature, appendHomeworldRevivalAmbassador, homeworldRevivalArrivalSignature, type HomeworldRevivalReturn, } from './homeworld-revival-return'; import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations'; import { terrorEntrySignature, terrorSelectionSignature, validateTerrorEntrySignature } from './terror-entry-receipt'; import { arrivalOverlapSignature, overlapOwnsWormTerror } from './arrival-overlap'; import { homeworldLowBonus, homeworldRevivalKaramaBlock, homeworldSardaukarFreeSupport, homeworldSardaukarGholaBlock, snapshotHomeworldRevival, tleilaxuHomeworldFreeIncomeBlocked, HomeworldBenefitError, type HomeworldRevivalOpening, } from './homeworld-benefits'; import { quoteEcazAlliance, ecazAllianceBlock, EcazAllianceError, } from './ecaz-alliance'; import { createHomeworldCustody, HomeworldCustodyError, type HomeworldCustody, } from './homeworld-custody'; import { quoteGiediCollectionReceipt, validateGiediCollection, type GiediCollection, } from './giedi-collection'; import { homeworldContext, homeworldGameIntegrity, homeworldTable, } from './homeworld-game'; import { quoteEmperorHomeworldMove, EmperorHomeworldMoveError, type EmperorHomeworld, } from './homeworld-emperor-move'; import { quoteNativeReserveWithdrawal, quoteNativeRevivalDeposit, NativeReserveError, type NativeReserveSelections, } from './homeworld-native-reserves'; import { validAmbassadorResume } from './ambassador-resume'; import { ecazOccupancyRelation } from './ecaz-occupy'; import { territoryEntryBlock, strongholdPathBlocked } from './occupancy'; import { validateGuildAmbassadorArrivalContext, GuildAmbassadorContinuationError, } from './guild-ambassador-continuation'; import { quoteGuildAmbassadorAdvisor, quoteGuildAmbassadorShipment, guildAmbassadorShipments, GuildAmbassadorShipmentError, type GuildAmbassadorShipment, } from './guild-ambassador'; import { quoteFremenAmbassadorMove, fremenAmbassadorMovement, FremenAmbassadorMoveError, type FremenAmbassadorMove, } from './fremen-ambassador'; import { quoteEcazDukeAcquisition, ecazDukeAcquisitionBlock, EcazDukeAcquisitionError, } from './ecaz-duke-acquisition'; import { quotePlacementCancellation, PlacementCancellationError, } from './placement-cancellation'; import { quotePhaseResources, PhaseResourceError, type PhaseResourceQuote, } from './phase-resource-quote'; import { quoteVictory, VictoryQuoteError, type VictoryQuote, } from './victory-quote'; import { fremenSpecialVictory, type FremenVictoryProgress } from './fremen-victory'; import { createSpiceAllocation, quoteSpiceAllocation, SpiceAllocationError, type AllocationState, type SpiceAllocationAction, type SpiceAllocationReceipt, } from './ecaz-spice-allocation'; import { strongholdProgress, VictoryProgressError, type StrongholdProgress, } from './victory-progress'; import { quoteMovementPhaseStart, quoteAdvisorBattleOffer, MovementPhaseQuoteError, type MovementPhaseQuote, } from './movement-phase-quote'; import { quoteRevivalResume, RevivalResumeError, type RevivalResumeQuote, } from './revival-resume'; import { quoteChoamWorthlessCancellation, ChoamWorthlessCancellationError, } from './choam-worthless-cancellation'; import { choamStormTerritories, quoteChoamStormOffer, ChoamStormQuoteError, type ChoamStormQuote, } from './choam-storm-quote'; import { quoteAuctionContinuation, AuctionContinuationError, type AuctionContinuationOperation, type AuctionContinuationQuote, } from './auction-continuation-quote'; import { quoteNormalAuctionBid, quoteNormalAuctionNext, } from './normal-auction'; import { quoteNormalAuctionPeek, quoteIxTechnologyCancellation, IxTechnologyCancellationError, } from './ix-technology-cancellation'; import { quoteIxAuctionDraw, IxAuctionDrawError, } from './ix-auction-draw-quote'; import { quoteMoritaniAllianceCancellation, MoritaniAllianceCancellationError, } from './moritani-alliance-cancellation'; import { quoteRicheseCancellation, RicheseCancellationError, } from './richese-cancellation'; import { quoteIxSubstitutionCancellation, IxSubstitutionCancellationError, } from './ix-substitution-cancellation'; import { quoteCombatResponse, isCombatResponseKind, CombatResponseQuoteError, type CombatResponseOperation, type CombatResponseQuote, } from './combat-response-quote'; import { quoteNoFieldCancellation, NoFieldCancellationError, } from './karama-no-field-cancellation'; import { validateTerminalCancellation, TerminalCancellationError, } from './terminal-cancellation'; import { quoteBattleAftermath, BattleAftermathQuoteError, type AftermathCancellation, } from './battle-aftermath-quote'; import { quoteSpiceCollection, quoteBattlePhaseAdvance, BoardResolutionError, type AdvisorRelease, } from './board-resolution-quote'; import { quoteDiscoveryCollection, DiscoveryCollectionError, } from './discovery-collection'; import { quoteMovementCancellation, validateAmbassadorRelocationContext, MovementCancellationError, } from './karama-movement-cancellation'; import { quoteChoamSaleCancellation, ChoamSaleCancellationError, } from './choam-sale-cancellation'; import { validateBattleForceLoss, ForceLossPreflightError, } from './force-loss-preflight'; import { quoteBattleResolution, BattleResolutionQuoteError, type ResolutionCombatant, type BattleResolutionQuote, } from './battle-resolution-quote'; import { quoteRevivalCancellation, RevivalCancellationError, } from './revival-cancellation'; import { validateStormTraversal, validateStormCancellation, validateWormCancellation, validateWormDevouring, DisasterPreflightError, } from './disaster-preflight'; import { quoteCompletedMovementArrival, MovementArrivalError, } from './karama-movement-preflight'; import { validateBattleCleanupContext, preflightMoritaniRetentionCancellation, KaramaBattlePreflightError, } from './karama-battle-preflight'; import { canceledResponseSource, KaramaContextError } from './karama-context'; import { validateSavedTruthtrance } from './truthtrance'; import { reorderOrderedOpportunity, protectOrderedOpportunityLast, validateOrderedOpportunity, type OrderedOpportunity, } from './ordered-opportunity'; import { matchesShipment, parseShipmentClaim, validShipmentClaim, shipmentClaimDestinations, liveShipmentPromises, shipmentPromiseModeSupported, type ShipmentClaim, type ShipmentPromise, } from './shipment-promises'; import { portableSnooperPlanBlock } from './portable-snooper'; import { createAuditorLeader, isAuditorLeader, auditCount, sampleAuditCards, } from './choam-auditor'; import { stoneBurnerPlanBlock } from './stone-burner'; import { STRONGHOLD_CARDS, createStrongholdCards, strongholdBenefit, strongholdControllers, settleStrongholdCards, strongholdSupportCost, type StrongholdId, type StrongholdState, } from './stronghold-cards'; import { residualPoisonCandidates, residualPoisonDeath, } from './residual-poison'; import { createRemainingCohort, validateCohortSelection, type MovementCohort, type OrnithopterMode, } from './ornithopter'; import { eligibleNullentropyCards, resolveNullentropyBox, } from './nullentropy-box'; import { transferDistrans } from './distrans'; import { SEMUTA_DRUG_ID, committedSemutaCandidates, resolveSemutaDrug, type FreshDiscardBatch, type SemutaContext } from './semuta-drug'; import { prepareRicheseGift, transferRicheseGift, type RicheseGiftIntent, } from './richese-gift'; import { richeseCards, richeseCardDefinition } from './richese-cards'; import { createRicheseNoField, deployRicheseNoField, deployRicheseNoFieldPair, validateRicheseNoFieldPair, moveRicheseNoField, revealRicheseNoField, shipAlliedRicheseNoField, projectRicheseNoField, type RicheseNoField, type NoFieldRevealCause, } from './richese-no-field'; import { presenceAt, presenceByLocation } from './force-presence'; import { createRicheseAuction, submitRicheseBid, projectRicheseAuction, reorderRicheseBidder, type RicheseAuction, } from './richese-auction'; import { richeseOwnCommitment, richeseAllyCommitment, validateRicheseFunding, type RicheseFunding, } from './richese-funding'; import { quoteRicheseSettlement, requireRicheseDeclarationCache, RicheseSettlementError, } from './richese-settlement'; import { validateAmbassadors, createAmbassadors, placeAmbassador, triggerAmbassador, destroyAmbassador, replenishAmbassadors, copiedAmbassadorEffects, canTriggerAmbassador, type AmbassadorEffect, type AmbassadorToken, type AmbassadorState, } from './ecaz-ambassadors'; import { createDukeVidal, acquireDuke, consumeDuke, expireDuke, DUKE_VIDAL_ID, type DukeState, } from './duke-vidal'; import { createNexusEcazDukeReceipt, quoteNexusEcazDuke, validateNexusEcazDuke, type NexusEcazDukeReceipt } from './nexus-ecaz-duke'; import { retentionReservesCard, type MoritaniRetention, } from './moritani-retention'; import { ordinaryCardAvailability } from './card-availability'; import { ecazDukeRevivalBlock, quoteEcazDukeRevival } from './ecaz-duke-revival'; import { reserveShipmentCost, guildShipmentCost, guildShipmentIncome, shipmentPaymentBounds, } from './shipment-price'; import { TERROR_DEFINITIONS, createTerrorState, placeTerror, TERROR_STRONGHOLDS, projectTerror, revealTerror, returnTerror, retireExtortion, type TerrorKind, type TerrorState, } from './moritani-terror'; import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionState, type ExtortionState } from './moritani-extortion'; import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions'; import { cashInCards } from './choam-karama'; import { saleOptions, quoteSale, type ChoamMarket } from './choam-market'; import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy'; import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type BiddingEnd } from './bidding-end'; import { quoteHomeworldPaymentIncome, quoteGuildPaymentRounding } from './homeworld-payment-income'; import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } from './ecaz-poison-income'; import { resolveBattleWeapons } from './effective-weapons'; import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola'; import { charityAmount, charityQuote, charityPayer, charityMultiplier, type Inflation, } from './charity'; import { PlanClaimError, respectsBattlePromises, type PlanClaim, type BattlePromise, } from './battle-promises'; import { resolveTruthAction, validateTruthQuestionReceipt, queuedTruthCardMatches, truthFactAnswer, TruthError, type TruthWindow, type TruthQueueEntry, type TruthRecord, type TruthAnswer, } from './truthtrance'; import { controlsLeader, nativeAvailable } from './leader-control'; import { newRevivalRules, eliteRevivalRemaining, paidForceRevivalCost, revivalPrevented, normalForceRevivalLimit, freeRevivalRate, revivalDiscount, forceRevivalQuote, forceRevivalRemaining, freeRevivalRemaining, normalRevivalAllowance, leaderRevivalOptions, type RevivalRules, type PendingRevival, } from './revival'; import { traitorDeck, matchingTraitor, CHEAP_HERO_TRAITOR } from './traitors'; import { chooseEcazLoyalty, withoutEcazLoyalty, validateEcazLoyalty, type EcazLoyalty } from './ecaz-loyalty'; import { isStoneBurner, isMirrorWeapon, isPortableSnooper, VOICE_KINDS, defaultVoiceMatch, playedVoiceMatch, } from './battle-cards'; import { mirrorWeaponModeBlock } from './mirror-weapon-mode'; import { TECH_TOKENS, createTechTokens, ownedTech, type TechId, type TechState, } from './tech-tokens'; import { canUseAsKaramaRole } from './shrine'; import { isAdvisor, fighterCount, settleAdvisors, arrivalAsAdvisor, } from './advisors'; import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat'; import { FACTIONS, faction, type FactionId } from './catalog'; import { quoteLobbyBotConfiguration } from './lobby-bot-configuration'; import { battleLeaderStrength, treacheryDeck, normalizeLegacyCardNames, leaders, spiceDeck, type Card, type Leader, type SpiceCard, } from './cards'; import { ecazTreacheryCards, ecazTreacheryDefinition } from './ecaz-cards'; import { isReinforcements, quoteReinforcements, ReinforcementsError } from './reinforcements'; import { RECRUITS_CARD_ID, type RecruitsPreview, } from './recruits'; import { TERRITORIES, MOBILE_STRONGHOLD, MOBILE_LOCATION, gameTerritories, gameDistance, mobileRouteDistance, FREMEN_START, territory, location, splitLocation, distance, validGameLocation, } from './board'; export type Player = { id: string; name: string; bot?: 'Easy' | 'Medium' | 'Hard' | 'Brutal'; autopilot?: 'Easy' | 'Medium' | 'Hard' | 'Brutal'; faction: FactionId; ready: boolean; spice: number; reserves: number; tanks: number; forces: Record; noField?: RicheseNoField; noFieldEvent?: string; noFieldBlockedTurn?: number; advisors?: Record; advisorSetup?: boolean; charityTurn?: number; specialKaramaUsed?: boolean; gholaBlocked?: Record; ixMovementBlocked?: { turn: number; move: number }; fremenMovementBlocked?: { turn: number; move: number }; fremenNexusMovementBlockedTurn?: number; fremenNexusWormBlockedTurn?: number; /** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */ atomicsHandLimitPenalty?: boolean; elites?: { reserves: number; tanks: number; forces: Record; revived: number; }; hand: Card[]; leaders: Leader[]; traitors: string[]; faceDancers?: { leader: string; revealed: boolean }[]; faceDancerReplacedTurn?: number; revealedTraitors?: string[]; traitorChoices: string[]; ally: string | null; /** Formation turn grants the printed next-turn co-occupation departure deadline. */ allySinceTurn?: number; bribes: number; revived: number; /** Actual free normal force revivals; absent when an older phase's history is unknown. */ freeForcesRevived?: number; leaderRevived: boolean; revivalCycle: number; shipped: boolean; moved: number; battleLosses: number; kwisatz?: { dead: boolean; usedAt?: string; revivalCycle?: number }; prediction?: { faction: FactionId; turn: number }; }; export type Plan = { /** Separate own-spice commitment for the selected Spice Banker; absent is zero. */ bankerSpice?: number; dial: number; leader: string | null; weapon: string | null; defense: string | null; support: number; allyPayment?: number; kwisatz?: boolean; }; export type PlanField = Exclude< keyof Plan, 'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice' >; export type DiplomatDefenseReceipt = DiplomatDefenseQuote & { event: string; player: string; battle: string; turn: number; territory: string; /** Only the revealed plans, played cards and public skill stance are bound. */ frame: string; stage: 'offered' | 'copied' | 'declined'; card: string | null; signature: string; }; export type HarassAllocationReceipt = { player: string; event: string; battle: string; turn: number; frame: string; stage: 'offered' | 'selected'; selection: HarassWithdrawSelection | null; signature: string; }; export type Battle = { /** New battles bind automatic collection to the original public plan reveal. */ smugglerCollectionVersion?: 1; smugglerCollection?: SmugglerBattleReceipt | null; /** New battles allow physical withdrawal choice after reveal, never in the sealed plan. */ harassAllocationVersion?: 1; harassAllocationEvent?: string; harassAllocation?: HarassAllocationReceipt; /** Scheduling owner; physical combat slots and aggressor remain independent. */ chooser?: string; chooserEvent?: string; saphoAggressor?: SaphoAggressorState; saphoAggressorEvents?: string[]; /** Only newly created supported battles enter the pre-plan Mentat question. */ mentatQuestionVersion?: 1; mentatQuestionEvent?: string; mentatQuestion?: MentatQuestionReceipt; /** New battles opt into the revealed Diplomat step; legacy battles are not reopened. */ diplomatDefenseVersion?: 1; diplomatDefenseEvent?: string; diplomatDefense?: DiplomatDefenseReceipt; /** New battles may pause after the outcome is known, before loser losses. */ diplomatRetreatVersion?: 1; diplomatRetreat?: { player: string; event: string; battle: string; turn: number; frame: string; destinations: DiplomatRetreatDestination[]; }; /** Public face-up/behind-shield choice precedes faction battle powers. */ leaderSkillHidden?: Record; nexusInspection?: NexusInspection; /** Independent presence marker: deleting a spent-card record is not a legacy save. */ nexusInspectionUsed?: string; nexusSardaukarUsed?: string; traitorDeclarationVersion?: 1; traitorDeclarations?: Record; homeworldDefensePassed?: string[]; event?: string; strongholdCopy?: StrongholdId; preLeader?: { event: string; ready: string[]; closed: boolean }; /** Public marker participation persists after a zero-token reveal. */ noFieldPlayers?: string[]; truthPromises?: BattlePromise[]; poisonTooth?: Record; stoneBurner?: Record; /** Supplemental revealed defense; never rewrites the original sealed plan. */ lateDefense?: Record; fullPlan?: { owner: string; target: string }; fullPlanOffered?: boolean; eliteBlocked?: string[]; fremenSupportBlocked?: boolean; choamAidBlocked?: boolean; powerChecks?: { kind: 'kwisatz' | 'eliteStrength' | 'fremenSupport' | 'choamBattleAid'; owner: string; }[]; kwisatzBlocked?: boolean; prepared?: boolean; territory: string; attacker: string; defender: string; plans: Record; revealed: boolean; traitorCalls: Record; preparation?: { kind: 'voice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer'; owner: string; beneficiary: string; }; prescience?: { player: string; field: PlanField; value?: string | number | null; }; voice?: { target: string; kind: Card['kind'] | 'stoneBurner'; must: boolean }; }; export type Auction = { peekKnown?: boolean; cards: Card[]; index: number; bid: number; bidder: string | null; active: string; passed: string[]; opener: number; allyPayment?: number; }; export type Decision = | { kind: 'harassWithdraw'; player: string; event: string } | { kind: 'diplomatDefense'; player: string; event: string; cards: string[]; source: string } | { kind: 'diplomatRetreat'; player: string; event: string; destinations: DiplomatRetreatDestination[] } | { kind: 'leaderSkillVisibility'; player: string; event: string; resumePowers?: boolean } | { kind: 'leaderSkillRevival'; player: string; event: string } | { kind: 'homeworldRevivalDeployment'; player: string; event: string } | { kind: 'grummanCollection'; player: string; event: string } | { kind: 'caladanReinforcement'; player: string; event: string } | { kind: 'choamAudit'; player: string; event: string } | { kind: 'nexusChoamInspection'; player: string; event: string } | { kind: 'choamAuditPayment'; player: string; event: string } | { kind: 'strongholdCopy'; player: string; event: string; choices: StrongholdId[]; } | { kind: 'richeseBlackMarket'; player: string } | { kind: 'richeseDeclaration'; player: string } | { kind: 'richeseCache'; player: string } | { kind: 'richeseUnbid'; player: string } | { kind: 'richeseAllyShipment'; player: string; owner: string } | { kind: 'richeseAllyOpportunity'; player: string; recipient: string } | { kind: 'ecazAmbassador'; player: string } | { kind: 'moritaniTerror'; player: string; entrant: string; territory: string; } | { kind: 'moritaniSetup'; player: string } | { kind: 'moritaniPlacement'; player: string } | { kind: 'moritaniExtortion'; player: string; event: string } | { kind: 'ecazPlacement'; player: string } | { kind: 'ecazSpice'; player: string } | { kind: 'choamStorm'; player: string; territories: { territory: string; amount: number; sectors: number[] }[]; protected: string[]; } | { kind: 'choamMovement'; player: string; mover: string; territory: string; sector: number; amount: number; } | { kind: 'choamMentat'; player: string } | { kind: 'choamFreeRevival'; player: string; recipient: string } | { kind: 'choamBattleFunding'; player: string } | { kind: 'choamMarket'; player: string } | { kind: 'choamTradeReply'; player: string } | { kind: 'choamTradeConfirm'; player: string } | { kind: 'ixSetup'; player: string } | { kind: 'ixAuction'; player: string } | { kind: 'ixTechnology'; player: string } | { kind: 'ixRicheseTechnology'; player: string; event: string; source: 'cache' | 'blackMarket' } | { kind: 'ixAllyCard'; player: string } | { kind: 'mobileStronghold'; player: string; placement: boolean } | { kind: 'ixSubstitution'; player: string; territory: string; losses: Record; } | { kind: 'revivalStop'; player: string; recipient: string; revival: 'forces' | 'leader'; } | { kind: 'faceDance'; blocked?: string; player: string; winner: string; leader: string | null; identity: string | null; territory: string; } | { kind: 'techToken'; player: string; loser: string; choices: TechId[] } | { kind: 'poisonTooth'; player: string; event?: string; physicalId?: string; copiedFrom?: string | null } | { kind: 'stoneBurner'; player: string; event: string; physicalId?: string; copiedFrom?: string | null } | { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] } | { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' } | { kind: 'fullPlanOffer'; player: string } | {kind:'bureaucratPayment';player:string;event:string} | { kind: 'moritaniAssassinate'; player: string; event: string } | { kind: 'mentatQuestion'; player: string; event: string; owner: string; target: string; stage: 'name' | 'reveal'; weapon: string | null } | { kind: 'fullPlanRead'; player: string; target: string } | { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string } | { kind: 'guildShipment'; /** Private original No-Field declaration; never projected to any seat. */ noFieldSkillProof?: string; player: string; shipper: string; territory: string; sector: number; amount: number; } | { kind: 'nullentropy'; player: string } | { kind: 'handExchange'; player: string; target: string; count: number } | { kind: 'battleLosses'; player: string; territory: string; options: Casualties[]; cards: string[]; } | { kind: 'homeworldDefense'; player: string; event: string } | { kind: 'homeworldExplosion'; player: string; territory: string; event: string; options: HomeworldForces[]; pool: HomeworldForces } | { kind: 'captureOffer'; player: string; loser: string; territory: string } | { kind: 'capturedLeader'; player: string; owner: string; leader: string; controller?: string; } | { kind: 'guildTiming'; player: string; next: string; following: string } | { kind: 'auctionPayment'; player: string } | { kind: 'advisor'; player: string; shipment: string; destination?: string; ambassadorEvent?: string; } | { kind: 'intrusion'; player: string; territory: string; wormRide?: boolean; discoveryEntry?: DiscoveryEntryArrivalChild; ambassadorEvent?: string; followup?: { shipment: string; destination: string }; } | { kind: 'advisorBattle'; player: string; territories: string[] } | { kind: 'stormLosses'; player: string; key: string; amount: number; minElite: number; maxElite: number; resume: 'storm' | 'shipment'; } | { kind: 'wormPlacement'; player: string } | { kind: 'wormProtection'; player: string; territory: string; ally: string } | { kind: 'discoveryDiscard'; player: string; event: string } | { kind: 'discoveryEntry'; player: string; event: string } | { kind: 'ecologicalStorm'; player: string; event: string } | { kind: 'greatMakerVote'; player: string; event: string } | { kind: 'greatMakerRide'; player: string; event: string } | { kind: 'wormRide'; player: string; territory: string } | { kind: 'nexusFremenCunningOffer'; player: string; event: string } | { kind: 'nexusFremenCunningRide'; player: string; event: string } | { kind: 'moritaniRetention'; owner: string; player: string; territory: string; cards: string[]; source?: 'nexus'; event?: string; } | { kind: 'battleCards'; player: string; territory: string; cards: string[] }; export type ResponseWindow = { bureaucratPayment?: BureaucratPaymentSource; bureaucratPaymentEvent?: string; noFieldSkillProof?: string; /** Original optional collection binds a pending ground movement, including saved responses. */ sandmasterProof?: string; /** Original eligible contributor amounts; private routing evidence, not a new payment. */ guildContributions?: number[]; guildPaymentProof?: string; /** Bank-paid portion of a frozen Homeworld charity claim. */ charityHomeworld?: number; source?: 'ambassador'; intent?: string; advisors?: boolean; /** Saved exact ordinary Guild-rate response; not the independent Karama rate. */ guildRateEvent?: string; advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry'; discoveryEntry?: DiscoveryEntryArrivalChild; advisorAmbassadorEvent?: string; advisorRemaining?: string[]; advisorFollowup?: { shipment: string; destination: string }; amount?: number; elite?: number; recipient?: string; location?: string; resume?: 'storm' | 'shipment'; take?: boolean; kind: | 'moritaniPlacement' | 'ecazPlacement' | 'ecazCollection' | 'moritaniAlliance' | 'moritaniDuke' | 'moritaniRetention' | 'voice' | 'prescience' | 'nexusPrescience' | 'advisor' | 'emperorIncome' | 'richesePurchaseIncome' | 'richeseGift' | 'richeseNoField' | 'harkonnenBonus' | 'harkonnenTraitor' | 'guildIncome' | 'guildRate' | 'emperorGift' | 'emperorRevival' | 'stormPeek' | 'stormProtection' | 'wormPlacement' | 'wormSurvival' | 'wormAllyProtection' | 'atreidesAuction' | 'atreidesSpice' | 'guildTiming' | 'kwisatz' | 'capture' | 'eliteStrength' | 'fremenSupport' | 'advisorFlip' | 'nexusAdvisorFlip' | 'nexusSardaukar' | 'nexusFremenCunning' | 'nexusEcazBetrayal' | 'nexusGuildCunning' | 'bgCharity' | 'choamCharity' | 'choamInflation' | 'choamSale' | 'choamWorthless' | 'choamBattleIncome' | 'choamAudit' | 'choamBattleAid' | 'worthlessKarama' | 'faceDancerReplacement' | 'choamRevival' | 'revivalLimit' | 'revivalDiscount' | 'earlyRevival' | 'revivalIncome' | 'foreignGhola' | 'ixSubstitution' | 'ixMovement' | 'fremenMovement' | 'mobileStronghold' | 'richeseAuction' | 'richeseBlackMarket' | 'ixAuction' | 'ixTechnology' | 'ixAllyCard'; owner: string; passed: string[]; }; type KaramaUse = | { kind: 'cancel'; response: ResponseWindow } | { kind: 'shipment'; recipient: string; card?: string } | { kind: 'purchase' } | { kind: 'auctionPayment' }; type PendingHomeworldShipment = HomeworldShipmentIntent & { guildSecretEvent?: string; guildNexusEvent?: string; /** Absent in older world-to-world declarations. */ route?: 'arrakis'; event: string; turn: number; amount: number; elite: number; cost: number; allyPayment: number; pools: ReturnType['sources'] | ReturnType['boardSources']; }; type PendingShipment = { /** Only new declarations use the bonus; legacy quoted shipments retain their original price. */ smuggler?: SmugglerShipment; smugglerCompanion?: SmugglerNoFieldCompanion; noFieldSkillProof?: string; guildSecretEvent?: string; guildRateEvent?: string; guildNexusEvent?: string; nexusEvent?: string; richesePair?: { event: string; revealedTokenId: string }; homeworldSources?: NativeReserveSelections; source?: 'ambassador'; ambassadorEvent?: string; /** Absent in legacy rooms; new declarations cannot resume in a later turn. */ turn?: number; player: string; territory: string; sector: number; amount: number; elite: number; cost: number; allyPayment: number; advisors: boolean; noField?: { tokenId: string; event: string }; alliedNoField?: RicheseAllyOffer; }; type PendingGuildTransport = { event: string; signature: string; turn: number; player: string; fromReserves: boolean; group: [string, number][]; eliteGroup: Record; origin: string; to: string; sector: number; amount: number; elite: number; cost: number; allyPayment: number; advisors: boolean; }; type GuildTransportOrder = Omit; type RicheseAllyOffer = { event: string; owner: string; recipient: string; tokenId: string; tokenEvent: string; territory: string; sector: number; payer: string; }; export type Game = { stormMovementSource?: StormMovementSource; ecologicalStorm?: DiscoveryStorm; discoveryEnabled?: boolean; discoveries?: DiscoveryState; discoveryStash?: DiscoveryStash; discoveryEntry?: DiscoveryEntryRound; greatMaker?: GreatMaker; nexusChoamTrades?: NexusChoamTrade[]; nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] }; nexusChoamBetrayals?: NexusChoamBetrayal[]; nexusChoamBetrayalLast?: { event: string; stage: NexusChoamBetrayal['stage'] }; nexusEcazInquiries?: EcazInquiryReceipt[]; nexusEcazInquiryLast?: { event: string }; nexusEcazDukeHistory?: NexusEcazDukeReceipt[]; nexusEcazDukeEvents?: string[]; nexusEmperorSecretHistory?: NexusEmperorRevival[]; nexusEmperorSecretEvents?: string[]; nexusEmperorPurchaseHistory?: EmperorNexusPurchase[]; nexusEmperorPurchaseEvents?: string[]; nexusFremenRevivalHistory?: FremenNexusRevival[]; nexusFremenRevivalEvents?: string[]; treacheryDiscardSequence?: number; resolvedTreacheryDiscardSequence?: number; /** Committed discard receipts are evidence, not additional card custody. */ pendingTreacheryDiscard?: { sequence: number; batch: FreshDiscardBatch; reaction?: | { stage: 'offer'; passed: string[] } | { stage: 'select'; player: string; candidates: string[] }; continuation: | { kind: 'ambassador'; entry: NonNullable } | { kind: 'discoveryStash'; event: string; owner: string; card: string } | { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number } | { kind: 'nexusChoamBetrayal'; event: string; owner: string; target: string; card: string; handBefore: number } | { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number } | { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: NonNullable } | { kind: 'nullentropyDiscard'; player: string; searchEvent: string; box: string; selected: Card; finalDiscardIds: string[]; finalDiscardSignature: string; resume: NonNullable['resume']; parentSignature: string; } | { kind: 'ordinaryCardDiscard'; player: string; card: string; effect: 'hajr' | 'ghola' | 'harvester' | 'weather' | 'atomics'; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'distransDiscard'; owner: string; recipient: string; transferred: string; card: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'saphoMovementDiscard'; player: string; card: string; event: string; mode: 'first' | 'last'; previousActive: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'saphoAuctionDiscard'; player: string; card: string; event: string; mode: 'first' | 'last'; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'saphoBattleOrderDiscard'; player: string; card: string; event: string; mode: 'first' | 'last'; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'saphoAggressorDiscard'; player: string; card: string; battleEvent: string; saphoEvent: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'residualPoisonDiscard'; player: string; target: string; victim: string; battleEvent: string; card: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard'; player: string; card: string; use: Extract; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'karamaPurchaseDiscard' | 'karamaPaymentDiscard'; player: string; card: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'choamSaleDiscard'; owner: string; card: string; witness: string | null; price: number; spiceAfter: number; stateSignature: string; } | { kind: 'choamKulonDiscard'; player: string; card: string; before: number; stateSignature: string; } | { kind: 'choamLaLaLaDiscard'; player: string; card: string; target: string; stateSignature: string; } | { kind: 'choamBalisetDiscard'; player: string; card: string; target: string; territory: string; stateSignature: string; } | { kind: 'choamGamontDiscard'; player: string; card: string; target: string; location: string; stateSignature: string; } | { kind: 'thumperDiscard'; player: string; card: string; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'amalDiscard'; player: string; card: string; before: { player: string; spice: number }[]; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'truthtranceDiscard'; consumed: TruthQueueEntry; historyIndex: number; record: TruthRecord; remaining: TruthWindow | null; resume: NonNullable['resume']; promise: | { kind: 'shipment'; index: number; value: ShipmentPromise } | { kind: 'battle'; index: number; value: BattlePromise } | null; parentSignature: string; } | { kind: 'ornithopterDiscard'; source: 'move' | 'end'; flight: NonNullable; movement: CompletedMovement | null; resume: NonNullable['resume']; stateSignature: string; } | { kind: 'terrorDiscard'; source: 'sabotage' | 'robberyOverflow'; owner: string; entry: NonNullable; discardedHandSize: number; } | { kind: 'terrorAtomics'; entry: NonNullable; aftermath: AtomicsAftermath; handBefore: { player: string; count: number }[]; } | { kind: 'battleResolved'; event: string; result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion'; combatants: string[]; territory: string; winner: string | null; cards: string[]; casualties?: { forces: CombatForces; dial: number; support: number; options: Casualties[]; }; } | { kind: 'battleCleanup'; event: string; combatants: string[]; territory: string; player: string; source: 'winner' | 'moritani'; kept: string[]; played: string[]; retention?: MoritaniRetention; } | { kind: 'ixAllyCard'; player: string; card: string; free: boolean; sale: NonNullable; auctionIndex: number | null; auctionEvent: string | null; }; } | null; ornithopter?: { event: string; player: string; card: Card; turn: number; mode: OrnithopterMode; startingMove: number; completed: number; cohort?: MovementCohort; } | null; pendingNullentropy?: { event: string; player: string; box: string; turn: number; phase: number; discardIds: string[]; discardSignature: string; resume: { response: Game['response']; decision: Game['decision']; pendingKarama: Game['pendingKarama']; phaseOpening: Game['phaseOpening']; }; } | null; pendingRicheseGift?: { event: string; intent: RicheseGiftIntent; turn: number; phase: number; resume: { response: Game['response']; decision: Game['decision']; pendingKarama: Game['pendingKarama']; phaseOpening: Game['phaseOpening']; }; } | null; richeseGiftBlocked?: { turn: number; phase: number; cards: string[] }; richeseAllyOffer?: RicheseAllyOffer | null; richeseAllyOpportunity?: { turn: number; recipient: string }; richeseAllyBlocked?: { turn: number; recipient: string }; richeseAllyDeclined?: { turn: number; recipient: string }; richeseCache?: Card[]; richeseRemoved?: Card[]; richeseBidding?: { owner: string; event: string; turn: number; stage: | 'blackMarketOffer' | 'declaration' | 'cacheOffer' | 'lot' | 'normal' | 'complete'; position: 'first' | 'last' | null; normalCount: number | null; blackMarketSold: boolean; cacheCanceled: boolean; opener?: number; } | null; richeseAuction?: RicheseAuction | null; richeseFunding?: RicheseFunding; richesePeekKnown?: boolean; richeseOfferedCard?: Card | null; richeseClaim?: string | null; /** Private pre-lot offer. The public Ixian choice contains no Black Market card identity. */ pendingIxRicheseTechnology?: { event: string; player: string; owner: string; round: string; turn: number; source: 'cache' | 'blackMarket'; card: Card; method: RicheseAuction['method']; direction: 'clockwise' | 'counterclockwise' | null; claim: string | null; frame: string; signature: string; }; ixRicheseTechnologyEvent?: string; currentAuctionSale?: { bureaucratPayment?: BureaucratPaymentSource; bureaucratPaymentEvent?: string; winner: string; amount: number; free: boolean; origin: 'normal' | 'cache' | 'blackMarket'; seller: string | null; } | null; pendingAmbassador?: { revivalEvent?: string; victoryEvent?: string; event: string; owner: string; entrant: string; token: string; territory: string; sector: number; turn: number; phase: number; stage: | 'offer' | 'allianceReply' | 'copy' | 'cards' | 'income' | 'bonus' | 'move' | 'ship' | 'arrival'; relocation?: { order: FremenAmbassadorMove; next: 'flip' | 'intrusion' | 'terror' | 'finish'; }; shipmentReceipt?: { order: GuildAmbassadorShipment; next: 'intrusion' | 'terror' | 'accompany' | 'advisor' | 'finish'; advisorArrival?: { player: string; territory: string; sector: number; amount: 1 | 2; elite: 0; }; }; purchaseReceipt?: { buyer: string; card: string; amount: 3; emperor: string | null; stage: 'income' | 'bonus'; }; beneficiary?: string; effect?: AmbassadorEffect; copyChoices: AmbassadorEffect[]; resume: 'none' | 'wormRide'; wormRider?: string; guildAdvisorOrigin?: { event: string; player: string; territory: string; sector: number; }; } | null; ambassadorInsights?: { event: string; viewer: string; target: string; turn: number; effect: 'atreides' | 'harkonnen'; cards: Card[]; traitor: string | null; }[]; ecazAmbassadors?: AmbassadorState; ecazPlacementTurn?: number; pendingEcazPlacement?: { token: string; territory: string; turn: number; cost: number; } | null; dukeVidal?: DukeState; dukeAcquisitionTurn?: number; pendingTerrorEntry?: { /** Original public arrival, absent only in legacy saves. */ entrySignature?: string; candidates?: string[]; selectionSignature?: string; nexusPlacements?: Record; token: string; entrant: string; territory: string; sector: number; amount: number; elite: number; cause: | 'shipment' | 'movement' | 'guildTransport' | 'advisor' | 'wormRide' | 'homeworldRevival' | 'caladanReinforcement' | 'ambassador'; turn: number; phase: number; stage: | 'select' | 'offer' | 'robbery' | 'discard' | 'gift' | 'sneakAttack' | 'allianceResponse' | 'allianceReply'; allianceBlocked?: boolean; resume: 'none' | 'wormRide' | 'ambassador'; ambassadorEvent?: string; } | null; /** One committed entry with two independently optional expansion reactions. */ pendingArrivalOverlap?: { event: string; signature: string; turn: number; phase: number; entrant: string; territory: string; sector: number; amount: number; elite: number; cause: NonNullable['cause']; resume: 'none' | 'wormRide'; ecaz: string; moritani: string; first: 'ambassador' | 'terror'; stage: 'first' | 'second'; ambassadorToken: string; terrorTokens: string[]; ambassadorEvent?: string; } | null; moritaniTerror?: TerrorState; moritaniAtomics?: AtomicsAftermath | null; moritaniExtortion?: ExtortionState; grummanCollection?: GrummanCollection; pendingMoritaniPlacement?: { token: string; territory: string; turn: number; nexusEvent?: string; } | null; pendingChoamMove?: MovementOrder | null; choamBaliset?: { turn: number; player: string; territory: string }[]; choamMentatPending?: boolean; choamMovement?: { turn: number; bonus: number }; choamWorthlessBlocked?: { turn: number; phase: number; cards: string[] }; pendingChoamWorthless?: { owner: string; card: string; effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba'; storm?: boolean; movement?: boolean; location?: string; elite?: number; mentat?: boolean; noFieldEvent?: string; target?: string; revival: boolean; nexusEvent?: string; } | null; pendingChoamBattleIncome?: { owner: string; amount: number } | null; pendingAuditor?: { event: string; owner: string; opponent: string; territory: string; turn: number; survived: boolean; usedCards: string[]; stage: 'offer' | 'response' | 'payment'; } | null; auditorInsight?: { event: string; viewer: string; target: string; turn: number; cards: Card[]; } | null; nexusChoamInsight?: { event: string; viewer: string; target: string; turn: number; card: Card; } | null; choamMarket?: ChoamMarket | null; biddingEnd?: BiddingEnd | null; ecazPoisonIncome?: { player: string; turn: number; phase: number; amount: number; count: number }[]; pendingWinnerDiscards?: { event: string; turn: number; territory: string; player: string; cards: string[]; optional: string[]; signature: string } | null; pendingSukRescue?: SukRescueReceipt | null; rihaniHistory?: RihaniReceipt[]; pendingChoamMarketGhola?: ChoamMarketGhola | null; choamTradeTurn?: number; inflation?: Inflation | null; inflationUsed?: boolean; inflationAttemptTurn?: number; choamCharity?: { turn: number; canceled: boolean }; homeworldRevival?: HomeworldRevivalOpening | null; homeworldRevivalReturn?: HomeworldRevivalReturn; homeworldRevivalProgress?: HomeworldRevivalProgress; homeworldVictoryReinforcement?: HomeworldVictoryReturn; giediCollection?: GiediCollection; ecazCollection?: { turn: number; event: string; stage: 'response' | 'allocation' | 'complete'; canceled: boolean; allocation: AllocationState | null; settled: SpiceAllocationReceipt[]; sourceSpice: Record; boardSignature: string; }; truthtrance?: TruthWindow | null; truthHistory?: TruthRecord[]; shipmentPromises?: ShipmentPromise[]; ixSetupCards?: Card[] | null; ixAuction?: { count: number; cards: Card[] } | null; ixAuctionKnown?: { turn: number; cards: Card[] } | null; ixTechnologyTurn?: number; pendingIxTechnology?: { card: string } | null; pendingIxAlly?: { player: string; card: string; free: boolean } | null; mobileStronghold?: { location: string | null } | null; pendingMobileMove?: { player: string; route: string[]; collect: boolean; } | null; revivalRules?: RevivalRules; revivalRequests?: Record< string, { leader: string; price: number | null; declined?: boolean } >; revivalFreeIncome?: Record; pendingRevival?: PendingRevival | null; pendingIxMove?: MovementOrder | null; pendingFremenMove?: { turn: number; move: number; order: MovementOrder; } | null; pendingIxSubstitution?: { receipt?: { event: string; turn: number; physical: string; signature: string }; homeworld?: { pool: HomeworldForces; cyborgsLost: number; eliteTanks: number; normalTanks: number; battleLosses: number }; player: string; territory: string; losses: Record; cards: string[]; sources?: Record; recover?: Record; } | null; revivalPrevention?: { player: string; turn: number }; traitorReserve?: string[]; ecazLoyalty?: EcazLoyalty; pendingFaceDance?: { player: string; winner: string; leader: string | null; identity: string | null; territory: string; } | null; phaseOpening?: { passed: string[]; initialize: boolean } | null; sandtrout?: boolean; techTokens?: TechState | null; strongholdCards?: StrongholdState | null; /** Optional independent module; complete effect coverage is still release-gated. */ nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null; nexusTraitorExchanges?: NexusTraitorExchange[]; /** An initially empty actual worm occurrence; the optional card is not disclosed by this offer. */ nexusFremenCunningOffer?: { occurrence: FremenCunningOccurrence; owner: string } | null; /** Accepted appearances wait for post-Nexus riding, then retain completion custody. */ nexusFremenCunningRides?: { occurrence: FremenCunningOccurrence; authorization: FremenCunningRideAuthorization; stage: 'pending' | 'queued' | 'select' | 'complete'; }[]; /** A spent Ecaz card binds one exact allied force group until Karama settles. */ nexusEcazBetrayalHistory?: { snapshot: EcazBetrayalSnapshot; stage: 'pending' | 'complete' }[]; /** Spent Fremen card receipts include the earlier movement-only saved form. */ nexusFremenBetrayalHistory?: StoredFremenNexusBetrayal[]; /** One actual Sardaukar battle advantage suppressed by a spent Emperor card. */ nexusEmperorBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; target: string }[]; /** Original native Voice canceled by a spent Bene Gesserit Nexus card. */ nexusBgBetrayalHistory?: { event: string; turn: number; territory: string; owner: string; target: string; voice: NonNullable }[]; nexusFaceDancerHistory?: NexusFaceDancerReceipt[]; nexusSuboidHistory?: NexusSuboidReceipt[]; nexusSuboidLast?: { event: string; owner: string; turn: number }; nexusAdvisorHistory?: { receipt: NexusAdvisorReceipt; stage: 'pending' | 'completed' | 'canceled'; frame: string; signature: string }[]; nexusAdvisorLast?: { event: string; stage: 'pending' | 'completed' | 'canceled' }; nexusSardaukarHistory?: {receipt: NexusSardaukarReceipt; stage: 'pending' | 'active' | 'canceled'; parent: string; signature: string; casualties?: {forces: CombatForces; dial: number; support: number; options: Casualties[]; outcome: 'pending' | 'complete'}}[]; nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'}; nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[]; nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[]; nexusGuildCunningHistory?: {receipt:NexusGuildCunningReceipt;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled';parent:string;movesAfter:number;shipment?:{kind:'reserve'|'guild'|'homeworld';frame:string;stage:'pending'|'complete'|'stopped'}|{kind:'declined';stage:'declined'};signature:string}[]; nexusGuildCunningLast?: {event:string;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled'}; nexusGuildSecretHistory?: {receipt:NexusGuildSecretAllyReceipt;kind:'reserve'|'cross'|'homeworld';frame:string;signature:string}[]; nexusGuildSecretLast?: {event:string}; nexusRicheseHistory?: {receipt:NexusRicheseReceipt;stage:'pending'|'shipped'|'stopped';frame:string;halfRate:boolean;signature:string}[]; nexusRicheseLast?: {event:string;stage:'pending'|'shipped'|'stopped'}; nexusRicheseCunningHistory?: { event: string; owner: string; turn: number; phase: 5; tokenEvent: string; frame: string; stage: 'pending' | 'shipped' | 'stopped'; signature: string }[]; nexusRicheseCunningLast?: { event: string; stage: 'pending' | 'shipped' | 'stopped' }; nexusMoritaniLocations?: Record; nexusMoritaniLast?: {event:string;stage:'pending'|'complete'|'canceled'}; nexusChoamLast?: {event: string; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'}; nexusTraitorPending?: string | null; nexusTraitorParent?: { event: string; signature: string } | null; /** Null/absent disables the module; custody is installed at force placement. */ homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null; homeworldOccupationHistory?: HomeworldOccupationHistory; tupileIntelligence?: TupileIntelligenceState; pendingTech?: { player: string; loser: string; choices: TechId[] } | null; summonedBeforeBlow?: boolean; summonedNexusBeforeRides?: boolean; summonedWorm?: { territory: string; /** Server-created identity for this one physical special-Karama appearance. */ event: string; resume: Pick< Game, | 'response' | 'decision' | 'pendingKarama' | 'spiceWindow' | 'spiceResolution' | 'spiceSequence' | 'nexus' | 'wormRides' | 'ready' >; } | null; pendingShipment?: PendingShipment | null; pendingGuildTransport?: PendingGuildTransport | null; /** One canceled unaffordable declaration may be redeclared at full price this turn. */ guildRateBlocked?: { turn: number; player: string }; pendingHomeworldShipment?: PendingHomeworldShipment | null; junctionOffer?: JunctionOffer | null; pendingExchange?: { response: ResponseWindow | null; decision: Decision | null; } | null; /** Private knowledge copies, never an additional physical card zone. */ harkonnenExchangeInspection?: { owner: string; target: string; turn: number; phase: 3; kind: 'draw' | 'return'; cards: Card[]; signature: string; }; /** Purchase is already committed; only the Emperor's receipt remains pending. */ pendingRichesePurchaseIncome?: { owner: string; turn: number; phase: number; resume: Pick; } | null; pendingKarama?: { owner: string; use: KaramaUse; /** New conversions bind their original power, shipment or lot. Absent in legacy saves. */ opportunity?: { kind: 'cancel' | 'shipment' | 'auction'; signature: string; }; } | null; schema: 1; botsPending?: boolean; botNextActionAt?: number; code: string; version: number; host: string; status: 'lobby' | 'setup' | 'playing' | 'finished'; /** Absent on legacy rooms whose starting cards were already dealt. */ setupStage?: 'prediction' | 'skillTreachery' | 'leaderSkills' | 'traitors' | 'forces'; leaderSkills?: LeaderSkillsState; bureaucratPayments?: {used:BureaucratPaymentUse[];pending?:{ source:BureaucratPaymentSource;owner:string;leader:string; resume:{response:ResponseWindow|null;decision:Decision|null}; continuation:{kind:'response'}|{kind:'auction';quote:AuctionContinuationQuote}|{kind:'bribe'}; frame:string;signature:string; }}; bureaucratPaymentEvent?: string; bureaucratUseEvents?: string[]; /** Private development opt-in pending the uniform Mentat response UX decision. */ mentatQuestionPreview?: true; /** Private development opt-in while uniform hidden-eligibility UX is pending. */ moritaniAssassinatePreview?: true; /** Explicit development-only neutral Semuta reaction profile. */ semutaPreview?: true; moritaniAssassinate?: MoritaniAssassinateState; moritaniAssassinateCallEvents?: string[]; moritaniAssassinateResume?: { event: string; continuation: Extract['continuation'], {kind:'battleResolved'}>; signature: string; }; mentatHistory?: MentatObservation[]; mentatHistoryEvents?: string[]; advanced: boolean; /** Explicit normal-lobby opt-in; unfinished rules remain labeled as a preview. */ advancedPreview?: true; expansions: string[]; players: Player[]; /** Fixed printed player circles; absent only in rooms created before this rule fix. */ playerPositions?: Record; turn: number; phase: number; storm: number; order: string[]; active: string | null; movementRemaining: string[] | null; /** Juice of Sapho's last position lasts only through this combined-turn queue. */ saphoMovementLast?: { event: string; turn: number; player: string } | null; /** Separate Battle-phase scheduling; never rewrites physical storm order. */ battleOrder?: BattleChooserOrder; battleOrderUseEvents?: string[]; guildTimingGranted: boolean; guildTimingLocked: boolean; ready: string[]; deck: Card[]; discard: Card[]; spiceDeck: SpiceCard[]; spicePeekKnown: boolean; spiceDiscard: SpiceCard[][]; spice: Record; log: { seq: number; text: string; automatic?: { faction: FactionId; name: string }; /** Public at the actual card reveal; never populated from a private forecast. */ component?: StormCardComponent; }[]; stormDials: Record; stormPending: number | null; stormCard: number | null; stormCardKnown: boolean; stormResolution: { discoveryStorm?: string; from: number; distance: number; traversed: number; pending: string[]; protected?: boolean; choamProtected?: string[]; } | null; karamaShipping: { player: string; owner: string; card?: string } | null; stormDialers: string[]; lastBattle: string[]; homeworldBattleLoss?: { event: string; territory: string; player: string; kind: 'winner' | 'explosion'; pool: HomeworldForces; options: HomeworldForces[]; commitment?: { forces: CombatForces; dial: number; support: number }; } | null; lastBattleContext?: { event: string; turn: number; territory: string; combatants: string[]; winner: string | null; result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion' | 'legacy'; cardRoles?: Record>>; cardRolesSignature?: string; mandatoryDiscard?: { entries: { card: string; owner: string }[]; signature: string; }; winnerDiscards?: { cards: string[]; completed: boolean; signature: string }; sukRescue?: { signature: string; completed: boolean }; ixSubstitution?: { signature: string; completed: boolean }; rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean; faceDanceStarted?: true }; sandmaster?: { leader: string; key: string; before: number; after: number }; smugglerCollection?: SmugglerBattleReceipt; caladanReinforcement?: HomeworldVictoryObligation; nexusSardaukarCasualties?: string; moritaniAssassinate?: {event:string;signature:string;continuation?:string}; nexusChoamInspection?: NexusChoamInspectionReceipt; nexusMoritaniRetention?: NexusMoritaniRetentionReceipt; } | null; auction: Auction | null; battle: Battle | null; nexus: boolean; allianceOffers: Record; winner: string[]; shieldWallDestroyed: boolean; spiceWindow: { territory: string; sector: number; amount: number; harvested: boolean; harvesters?: number; harvesterClosed?: boolean; } | null; hajr: string[]; decision: Decision | null; pendingCapture: { player: string; loser: string; territory: string } | null; moritaniRetention?: MoritaniRetention | null; response: ResponseWindow | null; wormRides: string[]; wormPlacementCanceledTurn: number; spiceResolution: { skipped: SpiceCard[] } | null; spiceSequence: { pile: 0 | 1; skipped: SpiceCard[] } | null; freeRevival: string[]; /** Presence enables the independently selected three-card Ecaz variant. */ ecazTreachery?: true; /** Public, turn-scoped receipt for the discarded Recruits card. */ recruits?: { turn: number; player: string; card: typeof RECRUITS_CARD_ID; } | null; emperorExtra: Record; aid: Record; }; export class RuleError extends Error {} function nexusRule(work: () => T): T { try { return work(); } catch (error) { throw new RuleError(error instanceof Error ? error.message : 'Invalid Nexus card operation.'); } } function nexusCardsIntegrity(g: Game) { const nexus = g.nexusCards; if (!nexus) return; requireRule(Object.keys(nexus).sort().join(',') === 'cards,phase', 'Invalid Nexus module fields.'); if (g.status === 'lobby') { requireRule(nexus.cards === null && nexus.phase === null, 'Nexus cards are dealt only after setup begins.'); return; } requireRule(nexus.cards, 'The Nexus inventory is missing.'); nexusRule(() => validateNexusCards(nexus.cards!, g.players)); requireRule(g.players.every(p => !p.ally || nexus.cards!.hands[p.id] === null), 'Allied players cannot retain Nexus cards.'); const phase = nexus.phase; if (!phase) { requireRule(g.turn === 1 && g.phase === 0, 'The Nexus phase record is missing.'); return; } nexusRule(() => validateNexusCardPhase(phase, g.players, nexus.cards!)); requireRule(phase.turn <= g.turn && phase.turn >= g.turn - 1 && (phase.stage === 'complete' || (phase.turn === g.turn && g.phase === 1)) && (g.phase !== 1 || phase.turn === g.turn), 'The Nexus record does not match its phase.'); if (phase.stage === 'drawing') requireRule(!g.decision && !g.response && !g.phaseOpening && !g.spiceSequence && !g.spiceWindow && !g.spiceResolution && !g.summonedWorm && !g.wormRides.length && !g.choamMarket && !g.truthtrance && !g.pendingKarama && !g.pendingTreacheryDiscard, 'Finish the preceding phase interaction before drawing Nexus cards.'); } function nexusChoamTradeIntegrity(g: Game) { const history = g.nexusChoamTrades; const continuation = g.pendingTreacheryDiscard?.continuation; const pending = continuation?.kind === 'nexusChoamTrade' ? continuation : null; if (history === undefined) { requireRule(!g.nexusChoamTradeLast && !pending, 'The CHOAM Nexus trade has lost its saved history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'The CHOAM Nexus trade requires its physical module and history.'); const events = new Set(), turns = new Set(); const physical = physicalTreacheryCards(g); for (const [index, record] of history.entries()) { nexusRule(() => validateNexusChoamTrade(g, record, physical)); requireRule(!events.has(record.event) && !turns.has(record.turn) && (record.stage === 'complete' || index === history.length - 1), 'The CHOAM Nexus trade has a duplicated or unfinished prior payment.'); events.add(record.event); turns.add(record.turn); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusChoamTradeLast) === JSON.stringify({event: last.event, stage: last.stage}) && (last.stage === 'discard') === !!pending, 'The CHOAM Nexus trade has lost or reopened its committed discard.'); if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('choam'), 'The CHOAM Nexus trade has lost its spent Nexus card.'); if (pending) requireRule(g.status === 'playing' && g.phase === 7 && last.turn === g.turn && pending.event === last.event && pending.owner === last.owner && pending.card === last.card && pending.spiceAfter === last.spiceBefore + 2 && getPlayer(g, last.owner).spice === pending.spiceAfter, 'The CHOAM Nexus trade no longer matches its committed payment.'); } function choamInspectionDecision( decision: Game['decision'] | undefined, ): Extract | null { return decision?.kind === 'nexusChoamInspection' ? decision : null; } function nexusChoamInspectionIntegrity(g: Game) { const context = g.lastBattleContext; const receipt = context?.nexusChoamInspection; const continuation = g.pendingTreacheryDiscard?.continuation; const active = choamInspectionDecision(g.decision); const exchange = choamInspectionDecision(g.pendingExchange?.decision); const nullentropy = choamInspectionDecision(g.pendingNullentropy?.resume.decision); const gift = choamInspectionDecision(g.pendingRicheseGift?.resume.decision); const purchase = choamInspectionDecision(g.pendingRichesePurchaseIncome?.resume.decision); const discard = choamInspectionDecision( continuation && 'resume' in continuation ? continuation.resume.decision : null, ); const decisionCount = Number(!!active) + Number(!!exchange) + Number(!!nullentropy) + Number(!!gift) + Number(!!purchase) + Number(!!discard); const inspectionDecision = active ?? exchange ?? nullentropy ?? gift ?? purchase ?? discard; if (!receipt) { requireRule(decisionCount === 0, 'The CHOAM Nexus inspection has lost its battle.'); return; } requireRule( !!g.nexusCards?.cards && !!context?.winner && context.result !== 'legacy' && receipt.event === context.event && context.combatants.length === 2 && context.combatants.includes(receipt.opponent) && receipt.opponent !== context.winner && Array.isArray(receipt.usedCards) && receipt.usedCards.every(id => typeof id === 'string' && id.length > 0) && new Set(receipt.usedCards).size === receipt.usedCards.length && ['pending', 'offer', 'complete'].includes(receipt.stage) && receipt.signature === nexusChoamInspectionSignature(receipt), 'The CHOAM Nexus inspection lost its original battle or used cards.', ); if (receipt.stage === 'offer') requireRule(g.phase === 6 && !g.battle && decisionCount === 1 && inspectionDecision?.event === receipt.event && inspectionDecision.player === context.winner, 'The CHOAM Nexus inspection offer has lost its winner.'); else requireRule(decisionCount === 0, 'The CHOAM Nexus inspection reopened after its battle.'); if (receipt.stage === 'pending') requireRule(g.phase === 6 && !g.battle, 'Finish the original battle cleanup before CHOAM inspection.'); if (receipt.inspected !== undefined) requireRule(receipt.stage === 'complete' && !receipt.usedCards.includes(receipt.inspected) && g.nexusChoamInsight?.event === receipt.event && g.nexusChoamInsight.viewer === context.winner && g.nexusChoamInsight.target === receipt.opponent && g.nexusChoamInsight.turn === context.turn && g.nexusChoamInsight.card.id === receipt.inspected, 'The private CHOAM Nexus inspection lost its sampled card.'); } function nexusMoritaniDecision( decision: Game['decision'] | undefined, ): Extract | null { return decision?.kind === 'moritaniRetention' && decision.source === 'nexus' ? decision : null; } function nexusMoritaniRetentionIntegrity(g: Game) { const context = g.lastBattleContext; const receipt = context?.nexusMoritaniRetention; const pending = g.moritaniRetention?.source === 'nexus' ? g.moritaniRetention : null; const continuation = g.pendingTreacheryDiscard?.continuation; const active = nexusMoritaniDecision(g.decision); const exchange = nexusMoritaniDecision(g.pendingExchange?.decision); const nullentropy = nexusMoritaniDecision(g.pendingNullentropy?.resume.decision); const gift = nexusMoritaniDecision(g.pendingRicheseGift?.resume.decision); const purchase = nexusMoritaniDecision(g.pendingRichesePurchaseIncome?.resume.decision); const worm = nexusMoritaniDecision(g.summonedWorm?.resume.decision); const discard = nexusMoritaniDecision( continuation && 'resume' in continuation ? continuation.resume.decision : null, ); const count = Number(!!active) + Number(!!exchange) + Number(!!nullentropy) + Number(!!gift) + Number(!!purchase) + Number(!!worm) + Number(!!discard); const decision = active ?? exchange ?? nullentropy ?? gift ?? purchase ?? worm ?? discard; if (!receipt) { requireRule(!pending && count === 0, 'The Moritani Nexus cleanup lost its battle.'); return; } requireRule( !!g.nexusCards?.cards && !!context?.winner && context.result !== 'legacy' && !byFaction(g, 'moritani') && context.event === receipt.event && context.combatants.includes(receipt.player) && context.winner !== receipt.player && Array.isArray(receipt.played) && Array.isArray(receipt.eligible) && new Set(receipt.played).size === receipt.played.length && new Set(receipt.eligible).size === receipt.eligible.length && receipt.played.every(id => typeof id === 'string' && !!id) && receipt.eligible.every(id => receipt.played.includes(id)) && ['pending', 'offer', 'complete'].includes(receipt.stage) && receipt.signature === nexusMoritaniRetentionSignature(receipt), 'The Moritani Nexus retention lost its physical losing battle.', ); if (receipt.stage === 'complete') { requireRule(!pending && count === 0 && (receipt.kept === null || receipt.eligible.includes(receipt.kept!)), 'The Moritani Nexus retention reopened after cleanup.'); return; } requireRule( g.phase === 6 && !g.battle && !!pending && pending.stage === 'choose' && pending.event === receipt.event && pending.owner === receipt.player && pending.player === receipt.player && pending.turn === context.turn && pending.territory === context.territory && JSON.stringify(pending.played) === JSON.stringify(receipt.played) && JSON.stringify(pending.eligible) === JSON.stringify(receipt.eligible) && pending.played.every(id => getPlayer(g, pending.player).hand.some(card => card.id === id)), 'The Moritani Nexus retention lost its reserved battle cards.', ); if (receipt.stage === 'offer') requireRule(count === 1 && decision?.player === receipt.player && decision.event === receipt.event && JSON.stringify(decision.cards) === JSON.stringify(receipt.eligible), 'The Moritani Nexus offer lost its original loser.'); else requireRule(count === 0, 'Finish earlier battle cleanup before Moritani Nexus retention.'); } function retireNexusChoamInsight(g: Game) { g.nexusChoamInsight = null; if (g.lastBattleContext?.nexusChoamInspection?.stage === 'complete') delete g.lastBattleContext.nexusChoamInspection; } function nexusChoamBetrayalIntegrity(g: Game) { const history = g.nexusChoamBetrayals; const continuation = g.pendingTreacheryDiscard?.continuation; const pending = continuation?.kind === 'nexusChoamBetrayal' ? continuation : null; if (history === undefined) { requireRule(!g.nexusChoamBetrayalLast && !pending, 'The CHOAM Nexus Betrayal has lost its spent card history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'CHOAM Nexus Betrayal requires its physical card and saved history.'); const events = new Set(); const physical = physicalTreacheryCards(g); for (const [index, record] of history.entries()) { nexusRule(() => validateNexusChoamBetrayal(g, record, physical)); requireRule(!events.has(record.event) && (record.stage === 'complete' || index === history.length - 1), 'The CHOAM Nexus Betrayal has a duplicated or unfinished prior discard.'); events.add(record.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusChoamBetrayalLast) === JSON.stringify({ event: last.event, stage: last.stage }) && (last.stage === 'discard') === !!pending, 'The CHOAM Nexus Betrayal has lost or reopened its committed discard.'); if (pending) requireRule(g.nexusCards.cards.discard.includes('choam'), 'The pending CHOAM Nexus Betrayal has lost its spent card.'); if (pending) requireRule(g.status === 'playing' && last.turn === g.turn && pending.event === last.event && pending.owner === last.owner && pending.target === last.target && pending.card === last.card && pending.handBefore === last.handBefore && getPlayer(g, last.target).hand.length === last.handBefore - 1, 'The CHOAM Nexus Betrayal no longer matches its random discard.'); } function nexusEcazInquiryIntegrity(g: Game) { const history = g.nexusEcazInquiries; if (history === undefined) { requireRule(!g.nexusEcazInquiryLast, 'The Ecaz Nexus inquiry lost its history.'); return; } requireRule(Array.isArray(history) && history.length > 0 && !!g.nexusCards?.cards, 'The Ecaz Nexus inquiry lost its physical card history.'); for (const [index, receipt] of history.entries()) { nexusRule(() => validateEcazInquiry(g, receipt, index)); if (index > 0) { const previous = history[index - 1]; requireRule(previous.turn < receipt.turn || (previous.turn === receipt.turn && previous.phase <= receipt.phase), 'The Ecaz Nexus inquiries changed their original order.'); } } requireRule(g.nexusEcazInquiryLast?.event === history.at(-1)!.event, 'The last Ecaz Nexus inquiry lost its original answer.'); } function nexusEcazDukeIntegrity(g: Game) { const history = g.nexusEcazDukeHistory, events = g.nexusEcazDukeEvents; if (history === undefined) { requireRule(events === undefined, 'Ecaz Nexus Duke lost its saved acquisition history.'); return; } requireRule(Array.isArray(history) && history.length > 0 && !!g.nexusCards?.cards && Array.isArray(events) && events.length === history.length && new Set(events).size === events.length, 'Ecaz Nexus Duke lost its physical card or independent use markers.'); for (const [index, receipt] of history.entries()) { nexusRule(() => validateNexusEcazDuke(g, receipt, index)); requireRule(events[index] === receipt.event, 'Ecaz Nexus Duke acquired from a changed event.'); } if (history.at(-1)!.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('ecaz'), 'Ecaz Nexus Duke has reopened its spent physical card.'); } function currentNexusChoamTrade(g: Game, owner: string) { return nexusRule(() => quoteNexusChoamTrade(g, owner, g.phase === 7 && grummanCollectionAutomatic(g))); } function emperorBetrayalWindow(g: Game) { const response = g.response, battle = g.battle; return !!(g.nexusCards?.cards && g.status === 'playing' && g.phase === 6 && battle && !battle.revealed && battle.event && response?.kind === 'eliteStrength' && response.owner === byFaction(g, 'emperor')?.id && (response.owner === battle.attacker || response.owner === battle.defender) && !battle.eliteBlocked?.includes(response.owner)); } function nexusEmperorBetrayalOffer(g: Game, owner: string) { const player = g.players.find((p) => p.id === owner); if (g.truthtrance || pendingNexusTraitors(g) || !emperorBetrayalWindow(g) || !player || player.ally || owner === g.response!.owner || g.response!.passed.includes(owner) || g.nexusCards!.cards!.hands[owner] !== 'emperor') return null; return { event: g.battle!.event!, target: g.response!.owner }; } function nexusEmperorBetrayalIntegrity(g: Game) { const history = g.nexusEmperorBetrayalHistory; if (history === undefined) return; requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Emperor Betrayal has lost its physical card module.'); const events = new Set(); for (const record of history) { requireRule(record && typeof record.event === 'string' && record.event.length > 0 && Number.isInteger(record.turn) && record.turn > 0 && record.turn <= g.turn && typeof record.territory === 'string' && record.territory.length > 0 && record.owner !== record.target && g.players.some(p => p.id === record.owner) && g.players.some(p => p.id === record.target && p.faction === 'emperor') && !events.has(record.event), 'Emperor Betrayal has a malformed or repeated battle receipt.'); events.add(record.event); } const last = history.at(-1)!; if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'), 'Emperor Betrayal has lost its spent physical card.'); if (g.battle?.event === last.event) requireRule(g.battle.territory === last.territory && g.battle.eliteBlocked?.includes(last.target), 'The current battle has lost its Emperor Betrayal suppression.'); } function playNexusEmperorBetrayal(g: Game, p: Player, action: Action) { const offer = nexusEmperorBetrayalOffer(g, p.id); requireRule(offer && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', 'Choose your current Emperor Sardaukar battle response.'); const response = g.response!; const record = { event: offer.event, turn: g.turn, territory: g.battle!.territory, owner: p.id, target: response.owner }; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusEmperorBetrayalHistory ??= []).push(record); finishResponse(g, true); log(g, `${p.name} spent Emperor Nexus Betrayal to suppress ${getPlayer(g, record.target).name}'s Sardaukar strength in this battle. Actual starred counters remain Sardaukar.`, { faction: p.faction, name: 'Emperor Nexus Betrayal' }); } function nexusFremenBetrayalIntegrity(g: Game) { const history = g.nexusFremenBetrayalHistory; const fremen = byFaction(g, 'fremen'); if (history === undefined) { requireRule(fremen?.fremenNexusMovementBlockedTurn === undefined && fremen?.fremenNexusWormBlockedTurn === undefined, 'Fremen Betrayal restrictions require a spent Nexus card receipt.'); return; } requireRule(g.nexusCards?.cards && fremen && Array.isArray(history) && history.length > 0, 'Fremen Betrayal has lost its physical card module.'); const turns = new Set(); let latestMovement: number | undefined; let latestWorm: number | undefined; let lastMode: FremenNexusBetrayal['mode'] = 'movement'; for (const record of history) { const mode = nexusRule(() => validateFremenBetrayal(g, record)); requireRule(!turns.has(record.turn), 'Fremen Betrayal cannot suppress two advantages in one turn.'); turns.add(record.turn); if (mode === 'worm') latestWorm = record.turn; else latestMovement = record.turn; lastMode = mode; } const last = history.at(-1)!; requireRule(fremen.fremenNexusMovementBlockedTurn === latestMovement && fremen.fremenNexusWormBlockedTurn === latestWorm, 'Fremen Betrayal has lost its turn-long restriction.'); if (last.turn === g.turn) { requireRule(lastMode !== 'worm' || (g.nexusCards.phase?.turn === g.turn && !g.wormRides.length && !g.nexusFremenCunningOffer && !g.nexusFremenCunningRides?.some(row => row.occurrence.turn === g.turn && row.stage !== 'complete')), 'Fremen Betrayal cannot leave a current worm ride or Cunning offer.'); if (lastMode === 'movement' || g.nexusCards.phase?.stage === 'spice') requireRule(g.nexusCards.cards.discard.includes('fremen'), 'Fremen Betrayal has lost its spent physical card.'); } } function playNexusFremenBetrayal(g: Game, p: Player, action: Action) { const offer = fremenBetrayalOffer(g, p.id); requireRule(offer && !offer.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', 'Use Fremen Betrayal before its current printed advantage begins.'); const record: FremenNexusBetrayal = { event: offer.event, owner: p.id, target: offer.target, turn: g.turn, phase: offer.mode === 'worm' ? 1 : 5, mode: offer.mode, signature: '', }; record.signature = fremenBetrayalSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); const fremen = getPlayer(g, offer.target); if (offer.mode === 'worm') fremen.fremenNexusWormBlockedTurn = g.turn; else fremen.fremenNexusMovementBlockedTurn = g.turn; (g.nexusFremenBetrayalHistory ??= []).push(record); log(g, offer.mode === 'worm' ? `${p.name} spent Fremen Nexus Betrayal before the first blow to suppress Fremen worm riding for this turn. Worm appearances and protection still resolve; ordinary and remote rides do not.` : `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`, { faction: p.faction, name: 'Fremen Nexus Betrayal' }); } function bgBetrayalWindow(g: Game) { const response = g.response, battle = g.battle; return !!(g.nexusCards?.cards && g.status === 'playing' && g.phase === 6 && battle && !battle.revealed && battle.event && battle.voice && response?.kind === 'voice' && response.owner === byFaction(g, 'beneGesserit')?.id && (battle.voice.target === battle.attacker || battle.voice.target === battle.defender)); } function nexusBgBetrayalOffer(g: Game, owner: string) { const player = g.players.find(p => p.id === owner); if (g.truthtrance || pendingNexusTraitors(g) || !bgBetrayalWindow(g) || !player || player.ally || owner === g.response!.owner || g.response!.passed.includes(owner) || g.nexusCards!.cards!.hands[owner] !== 'beneGesserit') return null; return { event: g.battle!.event!, target: g.response!.owner }; } function nexusBgBetrayalIntegrity(g: Game) { const history = g.nexusBgBetrayalHistory; if (history === undefined) return; requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Bene Gesserit Betrayal has lost its physical card module.'); const events = new Set(); for (const record of history) { requireRule(record && typeof record.event === 'string' && record.event.length > 0 && Number.isInteger(record.turn) && record.turn > 0 && record.turn <= g.turn && typeof record.territory === 'string' && record.territory.length > 0 && record.owner !== record.target && g.players.some(p => p.id === record.owner) && g.players.some(p => p.id === record.target && p.faction === 'beneGesserit') && record.voice && typeof record.voice.must === 'boolean' && VOICE_KINDS.includes(record.voice.kind) && g.players.some(p => p.id === record.voice.target) && !events.has(record.event), 'Bene Gesserit Betrayal has a malformed or repeated Voice receipt.'); events.add(record.event); } const last = history.at(-1)!; if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('beneGesserit'), 'Bene Gesserit Betrayal has lost its spent physical card.'); if (g.battle?.event === last.event) requireRule(g.battle.territory === last.territory && !g.battle.voice, 'The current battle has regained its canceled Voice.'); } function playNexusBgBetrayal(g: Game, p: Player, action: Action) { const offer = nexusBgBetrayalOffer(g, p.id); requireRule(offer && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', 'Choose your current Bene Gesserit Voice response.'); const record = { event: offer.event, turn: g.turn, territory: g.battle!.territory, owner: p.id, target: g.response!.owner, voice: structuredClone(g.battle!.voice!) }; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusBgBetrayalHistory ??= []).push(record); finishResponse(g, true); log(g, `${p.name} spent Bene Gesserit Nexus Betrayal to prevent ${getPlayer(g, record.target).name}'s Voice in this battle. The battle and any later Prescience remain available.`, { faction: p.faction, name: 'Bene Gesserit Nexus Betrayal' }); } /** The public payment window must not depend on who privately holds Emperor Nexus. */ function emperorNexusUniformAuctionPayment(g: Game): boolean { return !!g.nexusCards?.cards && !byFaction(g,'emperor') && emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout && !!g.auction && !g.currentAuctionSale; } function nexusEmperorSecretAllyOffer(g:Game,owner:string) { const p = g.players.find(p => p.id === owner); if (!p || g.nexusCards?.cards?.hands[owner] !== 'emperor' || byFaction(g,'emperor')) return null; const purchasing = g.status === 'playing' && g.phase === 3 && g.decision?.kind === 'auctionPayment' && g.decision.player === owner && g.auction?.bidder === owner && !g.currentAuctionSale; const event = purchasing ? emperorNexusPurchaseEvent(g.turn,owner,g.nexusEmperorPurchaseHistory?.length ?? 0,g.auction!.index,g.auction!.bid) : emperorNexusEvent(g.turn,g.phase,owner,g.nexusEmperorSecretHistory?.length ?? 0); let purchase: {blocked:string|null;price:number}|null = null; if (purchasing) { let purchaseBlock:string|null = null; if (p.ally) purchaseBlock = 'Emperor Nexus Secret Ally requires an unallied buyer.'; else if (!emperorNexusModeSupported(g) || g.ecazTreachery || g.sandtrout) purchaseBlock = 'Emperor Nexus purchase currently requires classic factions and Nexus alone.'; else if (g.response || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingAmbassador || g.pendingCapture || g.battle || g.nexusTraitorPending || g.nexusCards?.phase?.stage === 'drawing') purchaseBlock = 'Finish the current interaction before this auction payment.'; else { try { normalKaramaAuction(g,p,true); quoteEmperorNexusPurchase(g.auction!.bid,p.spice,g.auction!.allyPayment ?? 0); } catch (error) { purchaseBlock = error instanceof Error ? error.message : 'The buyer cannot prove the full price.'; } } purchase = {blocked:purchaseBlock,price:g.auction!.bid}; } let blocked:string|null = null; if (p.ally) blocked = 'Emperor Nexus Secret Ally requires an unallied holder.'; else if (!emperorNexusModeSupported(g)) blocked = 'Emperor Nexus Secret Ally currently requires base factions and Nexus alone; expansion and other module combinations remain pending.'; else if (g.status !== 'playing' || g.phase !== 4) blocked = 'Use the three-force Emperor Nexus return during Revival.'; else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingAmbassador || g.pendingCapture || g.battle || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the current interaction before using Emperor Nexus revival.'; else if ((g.revivalPrevention?.turn === g.turn && g.revivalPrevention.player === owner) || g.revivalRules?.freeBlocked?.includes(owner) || g.revivalRules?.expanded.includes(owner) || g.freeRevival.includes(owner)) blocked = 'Emperor Nexus revival with free-revival suppression or expanded revival effects remains pending.'; const eliteOptions = emperorNexusRevivalElites(p,g.advanced); if (!blocked && !eliteOptions.length) blocked = 'Emperor Nexus needs exactly three eligible forces in Tanks; a smaller return remains pending.'; return {event,revival:{blocked,eliteOptions},purchase}; } function nexusEmperorSecretIntegrity(g:Game) { const history = g.nexusEmperorSecretHistory, events = g.nexusEmperorSecretEvents; if (history === undefined) requireRule(events === undefined,'Emperor Nexus has lost its saved revival history.'); else { requireRule(g.nexusCards?.cards && emperorNexusModeSupported(g) && Array.isArray(history) && history.length > 0 && Array.isArray(events) && events.length === history.length && new Set(events).size === events.length, 'Emperor Nexus has lost its physical module or independent use markers.'); for (const [index,record] of history.entries()) { nexusRule(() => validateEmperorNexusRevival(g,record)); requireRule(record.sequence === index && events[index] === record.event, 'Emperor Nexus has duplicated or reordered a completed revival.'); } const last = history.at(-1)!; if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'), 'Emperor Nexus has reopened its already spent physical card.'); } const purchases = g.nexusEmperorPurchaseHistory, purchaseEvents = g.nexusEmperorPurchaseEvents; if (purchases === undefined) requireRule(purchaseEvents === undefined,'Emperor Nexus has lost its saved purchase history.'); else { requireRule(g.nexusCards?.cards && emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout && Array.isArray(purchases) && purchases.length > 0 && Array.isArray(purchaseEvents) && purchases.length === purchaseEvents.length && new Set(purchaseEvents).size === purchaseEvents.length, 'Emperor Nexus purchase has lost its physical module or independent use markers.'); for (const [index,record] of purchases.entries()) { nexusRule(() => validateEmperorNexusPurchase(g,record)); requireRule(record.sequence === index && purchaseEvents[index] === record.event, 'Emperor Nexus purchase has duplicated or reordered a completed use.'); } const last = purchases.at(-1)!; if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'), 'Emperor Nexus purchase has reopened its already spent physical card.'); } } function playNexusEmperorRevive(g:Game,p:Player,action:Action) { const offer = nexusEmperorSecretAllyOffer(g,p.id); requireRule(offer && !offer.revival.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'elite,event,type' && offer.revival.eliteOptions.includes(action.elite as number), offer?.revival.blocked ?? 'Choose the current Emperor Nexus revival and an eligible exact three-force group.'); const elite = action.elite as number, before = emperorNexusPools(p); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); addRevivedReserves(g,p,EMPEROR_NEXUS_REVIVALS,elite); p.tanks -= EMPEROR_NEXUS_REVIVALS; if (p.elites) {p.elites.tanks -= elite;p.elites.revived += elite;} const record:NexusEmperorRevival = {kind:'revival',event:offer.event,owner:p.id,turn:g.turn,phase:4, faction:p.faction,advanced:g.advanced, sequence:g.nexusEmperorSecretHistory?.length ?? 0,elite,before,after:emperorNexusPools(p),signature:''}; record.signature = emperorNexusSignature(record); (g.nexusEmperorSecretHistory ??= []).push(record); (g.nexusEmperorSecretEvents ??= []).push(record.event); log(g,`${p.name} spent Emperor Nexus Secret Ally to return three forces${elite ? `, including ${elite} Fedaykin` : ''} from Tanks to reserves for free. Ordinary force and free-revival allowances are unchanged.`, {faction:p.faction,name:'Emperor Nexus revival'}); } function playNexusEmperorPurchase(g:Game,p:Player,action:Action) { const offer = nexusEmperorSecretAllyOffer(g,p.id), purchase = offer?.purchase; requireRule(purchase && !purchase.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', purchase?.blocked ?? 'Choose your current Emperor Nexus bank-auction payment.'); const auction = g.auction!; normalKaramaAuction(g,p,true); nexusRule(() => quoteEmperorNexusPurchase(auction.bid,p.spice,auction.allyPayment ?? 0)); const purchased = auction.cards[auction.index], auctionIndex = auction.index; requireRule(purchased && auction.bidder === p.id && g.decision?.kind === 'auctionPayment' && g.decision.player === p.id,'The original winning bank-auction lot is no longer available.'); const beforeSpice = p.spice; g.decision = null; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); settleAuction(g,true,false,true); const record:EmperorNexusPurchase = {kind:'purchase',event:offer.event,owner:p.id,turn:g.turn, phase:3,sequence:g.nexusEmperorPurchaseHistory?.length ?? 0,faction:p.faction, advanced:g.advanced,price:auction.bid,auctionIndex,card:purchased.id,beforeSpice,afterSpice:p.spice,signature:''}; record.signature = signEmperorNexusPurchase(record); nexusRule(() => validateEmperorNexusPurchase(g,record)); (g.nexusEmperorPurchaseHistory ??= []).push(record); (g.nexusEmperorPurchaseEvents ??= []).push(record.event); log(g,`${p.name} spent Emperor Nexus Secret Ally after proving the ${record.price}-spice winning bid, bought the Treachery card and kept their own spice. The physical Nexus card was discarded.`, {faction:p.faction,name:'Emperor Nexus purchase'}); } function nexusEcazBetrayalIntegrity(g: Game) { const history = g.nexusEcazBetrayalHistory; if (history === undefined) return; requireRule(Array.isArray(history) && history.length > 0 && g.nexusCards?.cards, 'Ecaz Betrayal has lost its spent card history.'); const events = new Set(); let pending = 0; for (const record of history) { const snapshot = record?.snapshot; requireRule(snapshot && typeof snapshot.event === 'string' && !events.has(snapshot.event) && Number.isSafeInteger(snapshot.turn) && snapshot.turn > 0 && snapshot.turn <= g.turn && Number.isSafeInteger(snapshot.discardIndex) && snapshot.discardIndex >= 0 && g.players.some(player => player.id === snapshot.owner && player.faction !== 'ecaz') && g.players.some(player => player.id === snapshot.ally) && ['pending', 'complete'].includes(record.stage), 'Ecaz Betrayal has lost its unique owner, ally or turn.'); events.add(snapshot.event); if (record.stage === 'pending') { pending++; requireRule(pending === 1 && record === history.at(-1) && ((g.response?.kind === 'nexusEcazBetrayal' && g.response.owner === snapshot.owner && g.response.intent === snapshot.event && g.response.location === snapshot.territory) || (g.pendingKarama?.use.kind === 'cancel' && g.pendingKarama.use.response.kind === 'nexusEcazBetrayal' && g.pendingKarama.use.response.intent === snapshot.event)), 'Ecaz Betrayal has lost its pending Karama response.'); nexusRule(() => validateEcazBetrayalSnapshot(g, snapshot, true)); } else if (snapshot.turn === g.turn) { requireRule(g.nexusCards!.cards!.discard[snapshot.discardIndex] === 'ecaz', 'Ecaz Betrayal has reopened its spent card.'); } } } function nexusFremenCunningIntegrity(g: Game) { const offer = g.nexusFremenCunningOffer; const rides = g.nexusFremenCunningRides; if (offer === undefined && rides === undefined) return; requireRule(g.nexusCards?.cards && (!rides || Array.isArray(rides)), 'Fremen Cunning has lost its physical Nexus module or saved rides.'); const suspended = g.summonedWorm?.resume; if (offer) { const occurrence = offer.occurrence; const decision = g.decision?.kind === 'nexusFremenCunningOffer' ? g.decision : suspended?.decision; requireRule(g.status === 'playing' && g.phase === 1 && occurrence.turn === g.turn && occurrence.initiallyEmpty && occurrence.event && byFaction(g, 'fremen')?.id === offer.owner && decision?.kind === 'nexusFremenCunningOffer' && decision.player === offer.owner && decision.event === occurrence.event, 'The empty worm appearance has lost its original public offer.'); if (occurrence.origin === 'summoned') requireRule( occurrence.parent && g.summonedWorm?.event === occurrence.parent && g.summonedWorm?.territory === occurrence.territory, 'The empty summoned worm has lost its original special Karama parent.', ); } const events = new Set(); for (const ride of rides ?? []) { const { occurrence, authorization, stage } = ride; requireRule(occurrence.initiallyEmpty && occurrence.event && !events.has(occurrence.event) && authorization.event === occurrence.event && authorization.turn === occurrence.turn && occurrence.turn <= g.turn && authorization.owner && getPlayer(g, authorization.owner).faction === 'fremen' && ['pending', 'queued', 'select', 'complete'].includes(stage), 'Fremen Cunning has lost its unique accepted worm appearance.'); requireRule( ['natural', 'additional', 'summoned'].includes(occurrence.origin) && (occurrence.origin !== 'summoned' || (typeof occurrence.parent === 'string' && occurrence.parent.length > 0 && (stage !== 'pending' || (g.summonedWorm?.event === occurrence.parent && g.summonedWorm?.territory === occurrence.territory)))), 'Fremen Cunning has lost its summoned worm parent.', ); events.add(occurrence.event); if (stage === 'complete') continue; requireRule(g.status === 'playing' && g.phase === 1 && occurrence.turn === g.turn && g.nexusCards!.cards!.discard.includes('fremen'), 'A pending Fremen ride has lost its spent physical card.'); if (stage === 'select') { const decision = g.decision?.kind === 'nexusFremenCunningRide' ? g.decision : suspended?.decision; requireRule(decision?.kind === 'nexusFremenCunningRide' && decision.player === authorization.owner && decision.event === occurrence.event, 'Fremen Cunning has lost its current rider choice.'); } if (stage === 'pending') { const live = g.response; const prior = suspended?.response; const liveKarama = g.pendingKarama; const priorKarama = suspended?.pendingKarama; requireRule( (live?.kind === 'nexusFremenCunning' && live.owner === authorization.owner && live.intent === occurrence.event) || (prior?.kind === 'nexusFremenCunning' && prior.owner === authorization.owner && prior.intent === occurrence.event) || (liveKarama?.use.kind === 'cancel' && liveKarama.use.response.kind === 'nexusFremenCunning' && liveKarama.use.response.intent === occurrence.event) || (priorKarama?.use.kind === 'cancel' && priorKarama.use.response.kind === 'nexusFremenCunning' && priorKarama.use.response.intent === occurrence.event), 'Fremen Cunning has lost its pending cancellation window.'); } } requireRule(!offer || !events.has(offer.occurrence.event), 'A worm occurrence cannot be both offered and already spent.'); } function nexusFremenRevivalIntegrity(g: Game) { const history = g.nexusFremenRevivalHistory, events = g.nexusFremenRevivalEvents; if (history === undefined) { requireRule(events === undefined, 'Fremen Nexus has lost its saved revival history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0 && Array.isArray(events) && history.length === events.length && new Set(events).size === events.length, 'Fremen Nexus has lost its physical card or independent use markers.'); for (const [index, record] of history.entries()) { nexusRule(() => validateFremenNexusRevival(g, record)); requireRule(record.event === events[index] && (index === 0 || record.turn >= history[index - 1].turn), 'Fremen Nexus has duplicated or reordered a completed return.'); } const last = history.at(-1)!; if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('fremen'), 'Fremen Nexus has reopened its already spent physical card.'); } function playNexusFremenRevive(g: Game, p: Player, action: Action) { const offer = fremenNexusRevivalOffer(g, p.id); requireRule(offer && !offer.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'elite,event,type' && offer.eliteOptions.includes(action.elite as number), offer?.blocked ?? 'Choose a current eligible three-force Fremen Nexus return.'); const elite = action.elite as number, before = fremenNexusRevivalPools(p); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); addRevivedReserves(g, p, FREMEN_NEXUS_FREE_FORCES, elite); p.tanks -= FREMEN_NEXUS_FREE_FORCES; p.revived += FREMEN_NEXUS_FREE_FORCES; p.freeForcesRevived = (p.freeForcesRevived ?? 0) + FREMEN_NEXUS_FREE_FORCES; if (p.elites) { p.elites.tanks -= elite; p.elites.revived += elite; } const record: FremenNexusRevival = { event: offer.event, owner: p.id, turn: g.turn, phase: 4, faction: p.faction, advanced: g.advanced, elite, before, after: fremenNexusRevivalPools(p), signature: '' }; record.signature = fremenNexusRevivalSignature(record); (g.nexusFremenRevivalHistory ??= []).push(record); (g.nexusFremenRevivalEvents ??= []).push(record.event); log(g, `${p.name} spent Fremen Nexus Secret Ally to return three forces${elite ? `, including ${elite} elite` : ''} from Tanks to reserves for free. This uses the ordinary three-force revival allowance.`, { faction: p.faction, name: 'Fremen Nexus revival' }); } function playNexusChoamTrade(g: Game, p: Player, action: Action) { const offer = currentNexusChoamTrade(g, p.id); requireRule(Object.keys(action).every(key => ['type', 'event', 'card'].includes(key)) && offer && !offer.blocked && action.event === offer.event && offer.cards.some(card => card.id === action.card), offer?.blocked ?? 'Choose your current CHOAM Nexus trade and one held Worthless card.'); requireRule(Number.isSafeInteger(p.spice) && p.spice >= 0 && Number.isSafeInteger(p.spice + 2), 'The CHOAM Nexus trade needs a valid spice balance.'); const record: NexusChoamTrade = { event: offer.event, turn: g.turn, owner: p.id, card: action.card as string, spiceBefore: p.spice, stage: 'discard', signature: '' }; record.signature = nexusChoamTradeSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); const card = discard(g, p, record.card); p.spice += 2; (g.nexusChoamTrades ??= []).push(record); g.nexusChoamTradeLast = { event: record.event, stage: record.stage }; stageTreacheryDiscard(g, 'nexus:choamTrade', [{card, discardedBy: p.id, publicFace: true}], {kind: 'nexusChoamTrade', event: record.event, owner: p.id, card: card.id, spiceAfter: p.spice}); log(g, `${p.name} spent CHOAM Nexus Secret Ally and discarded ${card.name} during Spice Collection to receive 2 spice from the bank. Both cards are spent.`, {faction: p.faction, name: 'CHOAM Nexus trade'}); } function playNexusChoamBetrayal(g: Game, p: Player, action: Action) { const offer = quoteNexusChoamBetrayal(g, p.id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)); requireRule(Object.keys(action).sort().join(',') === 'event,type' && offer && !offer.blocked && action.event === offer.event, offer?.blocked ?? 'Choose the current CHOAM Nexus Betrayal.'); const target = getPlayer(g, offer.target.id); requireRule(target.hand.length > 0, 'CHOAM Betrayal has no legal card to discard.'); const card = target.hand[Math.floor(random() * target.hand.length)]; const record: NexusChoamBetrayal = { event: offer.event, turn: g.turn, phase: g.phase, owner: p.id, target: target.id, card: card.id, handBefore: target.hand.length, stage: 'discard', signature: '', }; record.signature = nexusChoamBetrayalSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); const discarded = discard(g, target, card.id); (g.nexusChoamBetrayals ??= []).push(record); g.nexusChoamBetrayalLast = { event: record.event, stage: record.stage }; stageTreacheryDiscard(g, 'nexus:choamBetrayal', [{ card: discarded, discardedBy: target.id, publicFace: false }], { kind: 'nexusChoamBetrayal', event: record.event, owner: p.id, target: target.id, card: card.id, handBefore: record.handBefore }); log(g, `${p.name} spent CHOAM Nexus Betrayal. ${target.name} discarded one randomly selected Treachery card without receiving spice.`, { faction: p.faction, name: 'CHOAM Nexus Betrayal' }); } function playNexusEcazInquiry(g: Game, p: Player, action: Action) { const offer = quoteEcazInquiry(g, p.id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)); requireRule(Object.keys(action).sort().join(',') === 'event,target,type' && offer && !offer.blocked && action.event === offer.event && offer.targets.some(target => target.id === action.target), offer?.blocked ?? 'Choose the current Ecaz Nexus inquiry and a seated player.'); const target = getPlayer(g, action.target as string); const receipt: EcazInquiryReceipt = { event: offer.event, owner: p.id, ownerFaction: p.faction, target: target.id, targetFaction: target.faction, turn: g.turn, phase: g.phase, scope: 'native', answer: ecazInquiryAnswer(p, target), signature: '', }; receipt.signature = ecazInquirySignature(receipt); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusEcazInquiries ??= []).push(receipt); g.nexusEcazInquiryLast = { event: receipt.event }; log(g, `${p.name} spent Ecaz Nexus Secret Ally to ask whether ${target.name} holds any of their native leaders as Traitor Cards. The yes/no answer was delivered privately.`, { faction: p.faction, name: 'Ecaz Nexus inquiry' }); } function playNexusEcazDuke(g: Game, p: Player, action: Action) { const offer = nexusRule(() => quoteNexusEcazDuke(g, p.id)); requireRule(offer && !offer.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,type', offer?.blocked ?? 'Choose the current Ecaz Nexus Duke acquisition.'); const index = g.nexusEcazDukeHistory?.length ?? 0; const receipt = nexusRule(() => createNexusEcazDukeReceipt(g, p.id, index)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); g.dukeVidal = nexusRule(() => acquireDuke(g.dukeVidal!, p.id, g.turn, 'ecazNexus')); nexusRule(() => validateNexusEcazDuke(g, receipt, index)); (g.nexusEcazDukeHistory ??= []).push(receipt); (g.nexusEcazDukeEvents ??= []).push(receipt.event); log(g, `${p.name} spent Ecaz Nexus Cunning to take Duke Prad Vidal for this turn. The shared leader remains physically unique and returns after use or at turn end.`, { faction: p.faction, name: 'Ecaz Nexus Cunning' }); } function playMoritaniBetrayal(g: Game, p: Player, action: Action) { const offer = moritaniBetrayalOffer(g, p.id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)); requireRule(offer && !offer.blocked && action.event === offer.event && Object.keys(action).sort().join(',') === 'event,token,type' && offer.tokens.some(token => token.id === action.token), offer?.blocked ?? 'Choose a currently placed Terror token.'); const target = offer.tokens.find(token => token.id === action.token)!; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); g.moritaniTerror = nexusRule(() => returnTerror(g.moritaniTerror!, target.id, random)); if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[target.id]; log(g, `${p.name} spent Moritani Nexus Betrayal to return a hidden Terror token from ${territory(target.territory).name} to Moritani's supply without revealing its face.`, { faction: p.faction, name: 'Moritani Nexus Betrayal' }); } function currentEcazBetrayalResponse(g: Game, response: ResponseWindow) { const record = g.nexusEcazBetrayalHistory?.find(row => row.stage === 'pending' && row.snapshot.event === response.intent); requireRule(response.kind === 'nexusEcazBetrayal' && record && response.owner === record.snapshot.owner && response.location === record.snapshot.territory, 'The Ecaz Betrayal interruption has lost its spent card or territory.'); nexusRule(() => validateEcazBetrayalSnapshot(g, record.snapshot, true)); return record; } function finishEcazBetrayalResponse(g: Game, response: ResponseWindow, canceled: boolean) { const record = currentEcazBetrayalResponse(g, response); const ally = getPlayer(g, record.snapshot.ally); if (!canceled) validateReserveReturnCounters(ally, record.snapshot.total, 'Ecaz Betrayal'); record.stage = 'complete'; if (!canceled) { for (const key of Object.keys(record.snapshot.sectors)) { delete ally.forces[key]; if (ally.elites) delete ally.elites.forces[key]; } ally.reserves += record.snapshot.total.normal + record.snapshot.total.elite; if (ally.elites) ally.elites.reserves += record.snapshot.total.elite; observeOccupation(g); } log(g, canceled ? `Karama prevented the Ecaz Nexus return from ${territory(record.snapshot.territory).name}; the Nexus card remains spent.` : `${ally.name} returned ${record.snapshot.total.normal + record.snapshot.total.elite} forces from ${territory(record.snapshot.territory).name} to reserves under Ecaz Nexus Betrayal.`, { faction: ally.faction, name: 'Ecaz Nexus Betrayal' }); } function playEcazBetrayal(g: Game, p: Player, action: Action) { const offer = ecazBetrayalOffer(g, p.id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)); requireRule(offer && !offer.blocked && Object.keys(action).sort().join(',') === 'event,territory,type' && typeof action.event === 'string' && typeof action.territory === 'string', offer?.blocked ?? 'Choose one currently shared Ecaz territory.'); const snapshot = nexusRule(() => quoteEcazBetrayal(g, p.id, stringField(action.event), stringField(action.territory))); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusEcazBetrayalHistory ??= []).push({ snapshot, stage: 'pending' }); g.response = { kind: 'nexusEcazBetrayal', owner: p.id, intent: snapshot.event, location: snapshot.territory, passed: [] }; log(g, `${p.name} spent Ecaz Nexus Betrayal to require Ecaz’s ally to return all forces from ${territory(snapshot.territory).name}, unless Karama prevents the return.`, { faction: p.faction, name: 'Ecaz Nexus Betrayal' }); } function markNexusOccurred(g: Game) { if (!g.nexusCards) return; const phase = g.nexusCards.phase; requireRule(phase?.turn === g.turn && phase.stage === 'spice', 'A Nexus needs an open Spice Blow phase.'); g.nexusCards.phase = nexusRule(() => markNexusCardOccurred(phase)); } function discardAllianceNexusCards(g: Game, ...players: Player[]) { if (!g.nexusCards?.cards) return; for (const player of players) { if (!g.nexusCards.cards.hands[player.id]) continue; g.nexusCards.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, player.id, g.players)); log(g, `${player.name} discarded their held Nexus Card to enter the alliance.`, { faction: player.faction, name: 'Nexus alliance discard' }); } } function closeNexusCards(g: Game): boolean { const nexus = g.nexusCards; if (g.phase !== 1 || !nexus) return false; requireRule(nexus.phase?.turn === g.turn && nexus.cards, 'The Nexus phase record is missing.'); if (nexus.phase.stage === 'spice') { nexus.phase = nexusRule(() => closeNexusCardPhase(nexus.phase!, g.players)); if (nexus.phase.stage === 'drawing') log(g, 'Spice Blow and Nexus is ending. Unallied factions may draw a secret Nexus Card, replace their held card, or keep their current position. Alliances are settled.'); } return nexus.phase.stage === 'drawing'; } function decideNexusCard(g: Game, p: Player, action: Action) { const nexus = g.nexusCards; requireRule(g.status === 'playing' && g.phase === 1 && nexus?.cards && nexus.phase?.stage === 'drawing' && action.turn === nexus.phase.turn && action.card === nexus.cards.hands[p.id] && Object.keys(action).sort().join(',') === 'card,choice,ownRedraws,turn,type' && [0, 1, 2].includes(action.ownRedraws as number), 'Choose your current Nexus card opportunity.'); const choice = action.choice as NexusCardChoice; requireRule(nexusCardChoices(nexus.phase, nexus.cards, p.id).includes(choice), 'That Nexus choice is unavailable.'); requireRule(choice !== 'keep' || action.ownRedraws === 0, 'Keeping your card does not draw a replacement.'); if (choice === 'draw') nexus.cards = nexusRule(() => drawNexusCard(nexus.cards!, p.id, g.players, random)); else if (choice === 'replace') nexus.cards = nexusRule(() => replaceNexusCard(nexus.cards!, p.id, g.players, random)); if (choice !== 'keep') { // The player preselects this optional policy knowing their own faction's // printed effect. No private redraw prompt leaks its identity through // whether the public phase waits or advances. At most two redraws are // possible: twelve cards and at most six held cards leave other cards in // the recycled deck after drawing the same own card for a second time. for (let remaining = action.ownRedraws as number; remaining > 0 && nexus.cards.hands[p.id] === p.faction; remaining--) nexus.cards = nexusRule(() => replaceNexusCard(nexus.cards!, p.id, g.players, random)); } nexus.phase = nexusRule(() => finishNexusCardChoice(nexus.phase!, g.players, nexus.cards!, p.id, choice)); log(g, `${p.name} ${choice === 'keep' ? 'finished their Nexus card choice' : choice === 'draw' ? 'drew a secret Nexus Card' : 'discarded a Nexus Card and drew a secret replacement'}.`); if (nexus.phase.stage === 'complete') completePhase(g); } function projectedNexusCards(g: Game, id: string) { const nexus = g.nexusCards; if (!nexus?.cards) return null; return { ...projectNexusCards(nexus.cards, id, g.players), turn: nexus.phase?.turn ?? null, choices: nexusCardChoices(nexus.phase, nexus.cards, id), waiting: nexus.phase?.stage === 'drawing' ? nexus.phase.eligible.filter(owner => !nexus.phase!.done.includes(owner)) : [] }; } function nexusTraitorUniverse(g: Game) { // This describes the printed setup inventory; it never regenerates the deck // from current hands, captured leaders or Face Dancer custody. return withoutEcazLoyalty(traitorDeck(g.players.map(p => ({ leaders: [ ...leaders(p.faction), ...(g.advanced && p.faction === 'choam' ? [createAuditorLeader()] : []), ] })), g.expansions.includes('ix')), g.ecazLoyalty); } function nexusTraitorSnapshot(g: Game): NexusTraitorSnapshot { return { reserve: [...(g.traitorReserve ?? [])], players: g.players.map(p => ({ id: p.id, faction: p.faction, traitors: [...p.traitors], ...(p.faceDancers ? { faceDancers: structuredClone(p.faceDancers) } : {}), })) }; } function pendingNexusTraitors(g: Game) { return g.nexusTraitorExchanges?.find(exchange => exchange.stage === 'return') ?? null; } function nexusTraitorParentSignature(g: Game, event: string) { const continuation = g.pendingTreacheryDiscard?.continuation; const context = continuation && 'resume' in continuation ? continuation.resume : g; const resume = { response: context.response ?? null, decision: context.decision ?? null, pendingKarama: 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] ?? null : null, phaseOpening: 'phaseOpening' in context ? context.phaseOpening as Game['phaseOpening'] ?? null : null }; const battle = g.battle ? Object.fromEntries(Object.entries(g.battle).filter(([key]) => key !== 'truthPromises')) : null; return JSON.stringify({ event, turn: g.turn, phase: g.phase, status: g.status, active: g.active, order: g.order, ready: g.ready, battle, nullentropy: g.pendingNullentropy ?? null, controls: nullentropyParentSignature(g, resume, event) }); } function traitorDeclarationContext(g: Game): TraitorDeclarationContext { const b = g.battle!; return { event: b.event!, attacker: b.attacker, defender: b.defender, plans: b.plans, players: g.players.map(({id,faction,ally,traitors}) => ({id,faction,ally,traitors})), heroLeaderIds: physicalTreacheryCards(g).filter(c => c.kind === 'hero').map(c => c.id), }; } function traitorDeclarationIntegrity(g: Game) { const b = g.battle; if (!b) return; if (b.traitorDeclarationVersion === undefined && b.traitorDeclarations === undefined) return; nexusRule(() => validateTraitorDeclarations(traitorDeclarationContext(g), b.traitorCalls, b.traitorDeclarations, b.traitorDeclarationVersion)); } function ensureTraitorDeclarations(g: Game) { const b = g.battle; if (!b?.revealed || b.traitorDeclarationVersion) return; requireRule(b.event, 'This battle predates the durable traitor declaration record.'); b.traitorDeclarationVersion = 1; b.traitorDeclarations = {}; for (const [voter, called] of Object.entries(b.traitorCalls)) if (called) b.traitorDeclarations[voter] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), voter)); } function nexusTraitorIntegrity(g: Game) { const history = g.nexusTraitorExchanges; if (!history) { requireRule(!g.nexusTraitorPending && !g.nexusTraitorParent, 'The pending Nexus exchange has lost its physical draw record.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history), 'Nexus exchanges require the original card module.'); const universe = nexusTraitorUniverse(g); nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), universe)); const events = new Set(); for (const exchange of history) { requireRule(!events.has(exchange.event) && exchange.turn <= g.turn, 'Nexus exchange history is duplicated or belongs to a future turn.'); events.add(exchange.event); if (exchange.stage === 'complete') nexusRule(() => validateNexusTraitorHistory(universe, exchange)); else requireRule(exchange.stage === 'return', 'Invalid Nexus exchange progress.'); } const pending = history.filter(exchange => exchange.stage === 'return'); requireRule(pending.length <= 1 && (pending[0]?.event ?? null) === (g.nexusTraitorPending ?? null), 'The Nexus exchange pending marker contradicts its draw history.'); if (pending.length) { const exchange = pending[0]; requireRule(g.status === 'playing' && exchange === history.at(-1) && exchange.turn === g.turn && exchange.phase === g.phase && g.nexusCards.cards.discard.includes('harkonnen'), 'The pending Nexus return has lost its turn, phase or spent card.'); nexusRule(() => validateNexusTraitorExchange(nexusTraitorSnapshot(g), universe, exchange)); requireRule(g.nexusTraitorParent?.event === exchange.event && g.nexusTraitorParent.signature === nexusTraitorParentSignature(g, exchange.event), 'The Nexus exchange has lost or changed its preceding game decision.'); } else requireRule(!g.nexusTraitorParent, 'A completed Nexus return cannot retain a suspended decision.'); traitorDeclarationIntegrity(g); } function nexusTraitorOffer(g: Game, id: string) { if (g.nexusCards?.cards?.hands[id] !== 'harkonnen') return null; const player = getPlayer(g, id), mode = nexusCardMode('harkonnen', player.faction, g.players.map(p => p.faction)); let blocked: string | null = null; if (g.status !== 'playing') blocked = 'Nexus effects belong to the started game.'; else if (player.ally) blocked = 'Allied players cannot retain or play a Nexus card.'; else if (g.truthtrance) blocked = 'Finish the active Truthtrance first.'; else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; else if (pendingNexusTraitors(g)) blocked = 'Finish the current Nexus return first.'; else if (mode === 'betrayal') blocked = 'Betrayal reactions await the private response timing decision.'; else if (mode === 'secretAlly' && g.phase !== 8) blocked = 'Use Secret Ally during Mentat Pause.'; const draw = mode === 'secretAlly' ? 2 : 1; return { event: JSON.stringify(['nexusTraitors', g.turn, g.phase, g.nexusTraitorExchanges?.length ?? 0, id]), mode, draw, blocked }; } function applyNexusTraitorSnapshot(g: Game, snapshot: NexusTraitorSnapshot) { g.traitorReserve = [...snapshot.reserve]; for (const entry of snapshot.players) { const player = getPlayer(g, entry.id); player.traitors = [...entry.traitors]; if (entry.faceDancers) player.faceDancers = structuredClone(entry.faceDancers); } } function playNexusTraitorDraw(g: Game, p: Player, action: Action) { const offer = nexusTraitorOffer(g, p.id); requireRule(offer && !offer.blocked && offer.mode !== 'betrayal' && action.event === offer.event && action.mode === offer.mode && Object.keys(action).sort().join(',') === 'event,mode,type', offer?.blocked ?? 'This Nexus Traitor draw is stale or belongs to another player.'); const result = nexusRule(() => beginNexusTraitorExchange(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), { event: offer.event, owner: p.id, mode: offer.mode as 'cunning' | 'secretAlly', turn: g.turn, phase: g.phase, })); ensureTraitorDeclarations(g); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); applyNexusTraitorSnapshot(g, result.state); (g.nexusTraitorExchanges ??= []).push(result.exchange); g.nexusTraitorPending = result.exchange.event; g.nexusTraitorParent = { event: result.exchange.event, signature: nexusTraitorParentSignature(g, result.exchange.event) }; log(g, `${p.name} played the Harkonnen Nexus Card: ${offer.mode === 'cunning' ? 'Cunning' : 'Secret Ally'}. They drew ${offer.draw} private ${p.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} ${offer.draw === 1 ? 'Card' : 'Cards'} and must choose ${offer.draw} to return before play continues.`, { faction: p.faction, name: 'Harkonnen Nexus' }); } function finishNexusTraitorReturn(g: Game, p: Player, action: Action) { const pending = pendingNexusTraitors(g); requireRule(pending?.owner === p.id && action.event === pending.event && Object.keys(action).sort().join(',') === 'cards,event,type' && Array.isArray(action.cards), 'This Nexus return is stale or belongs to another player.'); const result = nexusRule(() => finishNexusTraitorExchange(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), pending, action.cards as string[], random)); applyNexusTraitorSnapshot(g, result.state); g.nexusTraitorExchanges![g.nexusTraitorExchanges!.length - 1] = result.exchange; g.nexusTraitorPending = null; g.nexusTraitorParent = null; log(g, `${p.name} secretly returned ${result.exchange.returned.length} ${p.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} ${result.exchange.returned.length === 1 ? 'Card' : 'Cards'}. The Traitor Deck was shuffled; their other cards and any prior traitor declaration remain unchanged.`); } function projectedNexusTraitors(g: Game, id: string) { if (!g.nexusCards?.cards) return null; const pending = pendingNexusTraitors(g); const owner = pending ? getPlayer(g, pending.owner) : null; return { offer: nexusTraitorOffer(g, id), pending: pending ? { event: pending.event, owner: pending.owner, mode: pending.mode, count: pending.drawn.length, choices: pending.owner !== id ? [] : (owner!.faction === 'tleilaxu' ? owner!.faceDancers!.filter(card => !card.revealed).map(card => ({id:card.leader, revealed:card.revealed, drawn:pending.drawn.includes(card.leader)})) : owner!.traitors.map(card => ({id:card, revealed:false, drawn:pending.drawn.includes(card)}))), } : null }; } function nexusFaceDancerIntegrity(g: Game) { if (g.nexusFaceDancerHistory === undefined) return; requireRule(g.nexusCards?.cards && Array.isArray(g.nexusFaceDancerHistory), 'Face Dancer Nexus history requires its original module.'); const universe = nexusTraitorUniverse(g), events = new Set(); nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), universe)); for (const receipt of g.nexusFaceDancerHistory) { requireRule(!events.has(receipt.event) && receipt.turn <= g.turn && g.players.some(p => p.id === receipt.owner && p.faction === 'tleilaxu'), 'The Face Dancer replacement has lost its original owner or turn.'); events.add(receipt.event); nexusRule(() => validateNexusFaceDancerHistory(universe, receipt)); } } function projectedNexusTleilaxu(g: Game, id: string) { if (!g.nexusCards?.cards) return null; if (g.nexusCards.cards.hands[id] !== 'tleilaxu') return { cunning: null }; const p = getPlayer(g, id); if (p.faction !== 'tleilaxu') return { cunning: null }; const count = p.faceDancers?.filter(card => card.revealed).length ?? 0; let blocked: string | null = null; if (g.status !== 'playing') blocked = 'Use Cunning during an active game.'; else if (p.ally) blocked = 'Allied players cannot use a Nexus card.'; else if (g.truthtrance) blocked = 'Finish Truthtrance before replacing Face Dancers.'; else if (g.pendingTreacheryDiscard) blocked = 'Finish the automatic card disposal first.'; else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; else if (pendingNexusTraitors(g)) blocked = 'Finish the current private Traitor Deck return first.'; else if (!count) blocked = 'No revealed Face Dancers are available to replace.'; else if ((g.traitorReserve?.length ?? 0) < count) blocked = 'The Traitor Deck cannot supply the full replacement draw.'; return { cunning: { event: JSON.stringify(['nexusFaceDancers', g.turn, g.phase, g.nexusFaceDancerHistory?.length ?? 0, id]), count, blocked } }; } function playNexusFaceDancers(g: Game, p: Player, action: Action) { requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)), 'Cunning replaces every revealed Face Dancer; do not select a subset.'); const offer = projectedNexusTleilaxu(g, p.id)?.cunning; requireRule(offer && action.event === offer.event && !offer.blocked, offer?.blocked ?? 'Choose the current owned Face Dancer replacement.'); const result = nexusRule(() => replaceNexusFaceDancers(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), { event: offer.event, owner: p.id, turn: g.turn, phase: g.phase }, random)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); applyNexusTraitorSnapshot(g, result.state); (g.nexusFaceDancerHistory ??= []).push(result.receipt); log(g, `${p.name} set aside ${offer.count} revealed Face Dancer ${offer.count === 1 ? 'Card' : 'Cards'}, secretly drew replacements, then shuffled the set-aside cards into the Traitor Deck. Unrevealed Face Dancers and the normal Mentat replacement allowance are unchanged.`, { faction: p.faction, name: 'Nexus Face Dancer replacement' }); } function nexusSuboidIntegrity(g: Game) { if (g.nexusSuboidHistory === undefined) { requireRule(!g.nexusSuboidLast, 'The Suboid Nexus effect has lost its saved history.'); return; } requireRule(g.nexusCards?.cards, 'Suboid Nexus history requires its original module.'); nexusRule(() => validateNexusSuboids(g, g.nexusSuboidHistory!)); const last = g.nexusSuboidHistory.at(-1); requireRule(last && JSON.stringify(g.nexusSuboidLast) === JSON.stringify({ event: last.event, owner: last.owner, turn: last.turn }), 'The Suboid Nexus effect has lost its latest committed play.'); if (g.nexusSuboidHistory.some(receipt => receipt.turn === g.turn)) requireRule(g.nexusCards.cards.discard.includes('ixians'), 'The active Suboid effect has lost its spent Nexus card.'); } function projectedNexusSuboids(g: Game, id: string) { if (!g.nexusCards?.cards) return null; const ixians = byFaction(g, 'ixians'); const active = !!ixians && nexusSuboidsActive(g, g.nexusSuboidHistory, ixians.id); if (g.nexusCards.cards.hands[id] !== 'ixians' || ixians?.id !== id) return { offer: null, active }; const b = g.battle; let blocked: string | null = null; if (g.status !== 'playing' || g.phase !== 6 || !b || ![b.attacker, b.defender].includes(id)) blocked = 'Use Cunning in your battle before submitting your Battle Plan.'; else if (ixians.ally) blocked = 'Allied players cannot use a Nexus card.'; else if (b.revealed || b.plans[id]) blocked = 'Your Battle Plan is already submitted.'; else if (active) blocked = 'Your Suboids already have full strength this turn.'; else if (g.truthtrance || g.pendingTreacheryDiscard || pendingNexusTraitors(g)) blocked = 'Finish the current private question or card return first.'; else if (g.pendingNullentropy) blocked = 'Finish the paid Nullentropy search first.'; else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; const event = b?.event ? JSON.stringify(['nexusSuboids', g.turn, b.event, id]) : ''; if (!blocked) { const trial = structuredClone(g); (trial.nexusSuboidHistory ??= []).push(nexusRule(() => createNexusSuboids(trial, id, b!.event!))); trial.nexusSuboidLast = { event, owner: id, turn: g.turn }; trial.nexusCards!.cards = nexusRule(() => discardNexusCard(trial.nexusCards!.cards!, id, trial.players)); if (!findReachableBattlePlan(trial, getPlayer(trial, id))) blocked = 'Full-strength Suboids would prevent you from honoring an existing Battle Plan commitment.'; } return { offer: { event, blocked }, active }; } function playNexusSuboids(g: Game, p: Player, action: Action) { const offer = projectedNexusSuboids(g, p.id)?.offer; requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)) && offer && action.event === offer.event && !offer.blocked, offer?.blocked ?? 'Choose your current Ixian Cunning opportunity.'); commitNexusSuboids(g, p); } function commitNexusSuboids(g: Game, p: Player) { const receipt = nexusRule(() => createNexusSuboids(g, p.id, g.battle!.event!)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusSuboidHistory ??= []).push(receipt); g.nexusSuboidLast = { event: receipt.event, owner: receipt.owner, turn: receipt.turn }; log(g, `${p.name} used Ixian Cunning. Every Suboid has full strength without spice support in all battles for the rest of this turn. Cyborg strength, support and physical force counts are unchanged.`, { faction: p.faction, name: 'Nexus Suboid strength' }); } function nexusAdvisorFrame(g: Game, owner: string) { const p = getPlayer(g, owner); return JSON.stringify([g.status, g.turn, g.phase, g.active, g.movementRemaining, p.shipped, p.moved, g.hajr, g.guildTimingGranted, g.guildTimingLocked]); } function nexusAdvisorRecordSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature, record.stage, record.frame]); } function pendingNexusAdvisors(g: Game) { return g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null; } function validateNexusAdvisorResponse(g: Game, response: ResponseWindow) { const record = pendingNexusAdvisors(g); requireRule(record && response.kind === 'nexusAdvisorFlip' && response.owner === record.receipt.owner && response.intent === record.receipt.event && Object.keys(response).every(key => ['kind', 'owner', 'intent', 'passed'].includes(key)) && Array.isArray(response.passed) && new Set(response.passed).size === response.passed.length && response.passed.every(id => g.players.some(p => p.id === id)), 'The Nexus advisor conversion has lost its original response.'); requireRule(g.status === 'playing' && g.phase === 5 && g.active === record.receipt.owner && record.receipt.turn === g.turn && record.frame === nexusAdvisorFrame(g, record.receipt.owner), 'The Nexus advisor conversion has lost its original Shipment and Movement action.'); nexusRule(() => validateNexusAdvisors(g, record.receipt, true)); } function nexusAdvisorIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,g.bureaucratPayments?.pending?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; }).filter(response => response?.kind === 'nexusAdvisorFlip'); const history = g.nexusAdvisorHistory; if (history === undefined) { requireRule(!g.nexusAdvisorLast && !responses.length, 'The Nexus advisor conversion has lost its saved history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Nexus advisor history requires its original module and played card.'); const turns = new Set(); for (const [index, record] of history.entries()) { requireRule(record && record.receipt && typeof record.receipt === 'object' && Object.keys(record).sort().join(',') === 'frame,receipt,signature,stage' && ['pending', 'completed', 'canceled'].includes(record.stage) && typeof record.frame === 'string' && record.signature === nexusAdvisorRecordSignature(record) && record.receipt.event === JSON.stringify(['nexusAdvisors', record.receipt.turn, record.receipt.owner, index]) && !turns.has(record.receipt.turn) && (record.stage !== 'pending' || index === history.length - 1), 'The Nexus advisor conversion history is malformed.'); nexusRule(() => validateNexusAdvisors(g, record.receipt, record.stage === 'pending')); turns.add(record.receipt.turn); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusAdvisorLast) === JSON.stringify({event: last.receipt.event, stage: last.stage}), 'The Nexus advisor conversion has lost its latest outcome.'); requireRule((last.stage === 'pending') === (responses.length > 0), 'The Nexus advisor conversion has lost or reopened its cancellation response.'); if (last.receipt.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('beneGesserit'), 'The advisor conversion has lost its spent Nexus card.'); for (const response of responses) validateNexusAdvisorResponse(g, response!); } function projectedNexusAdvisors(g: Game, id: string) { if (!g.nexusCards?.cards) return null; const record = pendingNexusAdvisors(g); const pending = record ? {owner: record.receipt.owner, event: record.receipt.event, territories: record.receipt.selections.map(selection => selection.territory)} : null; const p = getPlayer(g, id); if (p.faction !== 'beneGesserit' || g.nexusCards.cards.hands[id] !== 'beneGesserit') return {offer: null, pending}; let blocked: string | null = null; if (!g.advanced) blocked = 'Advisors are available only in the Advanced game.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== id) blocked = 'Use Cunning during your own Shipment and Movement action.'; else if (p.ally) blocked = 'Allied players cannot use a Nexus card.'; else if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || pendingNexusTraitors(g) || record) blocked = 'Finish the current interaction before converting advisors.'; else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.'; const territories = nexusRule(() => quoteNexusAdvisors(g, id)).territories; if (!blocked && !territories.some(group => !group.blocked)) blocked = 'No advisor group is currently available to convert.'; return {offer: {event: JSON.stringify(['nexusAdvisors', g.turn, id, g.nexusAdvisorHistory?.length ?? 0]), blocked, territories}, pending}; } function playNexusAdvisors(g: Game, p: Player, action: Action) { const offer = projectedNexusAdvisors(g, p.id)?.offer; requireRule(Object.keys(action).every(key => ['type', 'event', 'territories'].includes(key)) && offer && action.event === offer.event && !offer.blocked, offer?.blocked ?? 'Choose your current Nexus advisor conversion.'); requireRule(Array.isArray(action.territories) && action.territories.every(t => typeof t === 'string'), 'Select whole advisor territories.'); const receipt = nexusRule(() => createNexusAdvisors(g, p.id, offer.event, action.territories as string[])); const record = {receipt, stage: 'pending' as const, frame: nexusAdvisorFrame(g, p.id), signature: ''}; record.signature = nexusAdvisorRecordSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusAdvisorHistory ??= []).push(record); g.nexusAdvisorLast = {event: receipt.event, stage: record.stage}; g.response = {kind: 'nexusAdvisorFlip', owner: p.id, intent: receipt.event, passed: []}; log(g, `${p.name} spent Bene Gesserit Nexus Cunning to convert all advisors in ${receipt.selections.map(s => territory(s.territory).name).join(', ')}. Karama may prevent this entire conversion.`, {faction: p.faction, name: 'Nexus advisor conversion declared'}); } function finishNexusAdvisors(g: Game, canceled: boolean) { const record = pendingNexusAdvisors(g)!; const p = getPlayer(g, record.receipt.owner); if (!canceled) for (const selection of record.receipt.selections) delete p.advisors![selection.territory]; record.stage = canceled ? 'canceled' : 'completed'; record.signature = nexusAdvisorRecordSignature(record); g.nexusAdvisorLast = {event: record.receipt.event, stage: record.stage}; log(g, canceled ? 'Karama prevented the entire Nexus advisor conversion. Every selected group remains advisors; the Nexus card stays spent.' : `${p.name} converted every advisor in ${record.receipt.selections.map(s => territory(s.territory).name).join(', ')} to fighters. The forces remain in place and no shipment, movement or spice was spent.`, {faction: p.faction, name: canceled ? 'Nexus advisor conversion prevented' : 'Nexus advisors become fighters'}); } function currentNexusSardaukar(g: Game) { return g.battle?.event ? g.nexusSardaukarHistory?.find(record => record.receipt.battle === g.battle!.event) ?? null : null; } function nexusSardaukarEffective(g: Game, id: string, to: string) { const record = currentNexusSardaukar(g); return record && record.receipt.owner === id && record.receipt.territory === to && record.stage === 'active' ? record.receipt.count : 0; } function nexusSardaukarParent(g: Game) { const b = g.battle!; return JSON.stringify([b.attacker, b.defender, b.territory, b.preparation ?? null, b.powerChecks ?? null, b.preLeader ?? null, b.plans]); } function nexusSardaukarSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature, record.stage, record.parent, record.casualties ?? null]); } function validateNexusSardaukarResponse(g: Game, response: ResponseWindow) { const record = currentNexusSardaukar(g), b = g.battle; requireRule(record?.stage === 'pending' && b && !b.revealed && record.receipt.turn === g.turn && record.receipt.territory === b.territory && response.kind === 'nexusSardaukar' && response.owner === record.receipt.owner && response.intent === record.receipt.event && Object.keys(response).every(key => ['kind', 'owner', 'intent', 'passed'].includes(key)) && Array.isArray(response.passed) && new Set(response.passed).size === response.passed.length && response.passed.every(id => g.players.some(p => p.id === id)) && record.parent === nexusSardaukarParent(g), 'The Nexus Sardaukar response has lost its original battle and preparation.'); const p = getPlayer(g, record.receipt.owner), other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const forces = combatForces(g, p, b.territory, other); requireRule(forces.normal === record.receipt.normal && forces.elite === 0, 'The pending Nexus Sardaukar declaration has lost its original eligible forces.'); } function nexusSardaukarIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; }).filter(response => response?.kind === 'nexusSardaukar'); const history = g.nexusSardaukarHistory; if (history === undefined) { requireRule(!g.nexusSardaukarLast && !g.battle?.nexusSardaukarUsed && !g.lastBattleContext?.nexusSardaukarCasualties && !responses.length, 'The Nexus Sardaukar use has lost its saved history.'); return; } requireRule(g.advanced && g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Nexus Sardaukar history requires its original Advanced module.'); const battles = new Set(), turns = new Set(); for (const [index, record] of history.entries()) { requireRule(record && record.receipt && typeof record.receipt === 'object' && Object.keys(record).sort().join(',') === (record.casualties ? 'casualties,parent,receipt,signature,stage' : 'parent,receipt,signature,stage') && ['pending', 'active', 'canceled'].includes(record.stage) && typeof record.parent === 'string' && record.signature === nexusSardaukarSignature(record) && !battles.has(record.receipt.battle) && !turns.has(record.receipt.turn) && (record.stage !== 'pending' || index === history.length - 1), 'The Nexus Sardaukar battle history is malformed.'); nexusRule(() => validateNexusSardaukar(g, record.receipt)); const losses = record.casualties; if (losses) { requireRule(record.stage === 'active' && ['pending','complete'].includes(losses.outcome) && Object.keys(losses).sort().join(',') === 'dial,forces,options,outcome,support' && validCombatForces(losses.forces) && losses.forces.temporaryElite === 5 && Array.isArray(losses.options) && losses.options.length > 0 && JSON.stringify(losses.options) === JSON.stringify(casualtyOptions(losses.forces,losses.dial,losses.support)), 'The Nexus Sardaukar casualties have lost their committed force allocation.'); if (losses.outcome === 'pending') { const context = g.lastBattleContext; requireRule(!g.battle && context?.event === record.receipt.battle && context.result === 'normal' && context.winner === record.receipt.owner && context.nexusSardaukarCasualties === record.receipt.event && context.territory === record.receipt.territory && context.turn === record.receipt.turn, 'The pending Sardaukar casualties have lost their completed battle.'); const pool = combatArmy(g,record.receipt.owner,record.receipt.territory); requireRule(pool.normal === losses.forces.normal && pool.elite === losses.forces.elite, 'The pending Sardaukar casualties have lost their exact physical army.'); const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'battleLosses' && d.player === record.receipt.owner); const committed = continuation?.kind === 'battleResolved' && continuation.event === record.receipt.battle ? continuation.casualties : null; requireRule(decisions.length > 0 || committed, 'The Nexus Sardaukar battle has lost its casualty choice.'); for (const decision of decisions) requireRule(decision.kind === 'battleLosses' && decision.territory === record.receipt.territory && JSON.stringify(decision.options) === JSON.stringify(losses.options), 'The casualty choices differ from the committed Nexus Sardaukar plan.'); if (committed) requireRule(JSON.stringify(committed) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options}), 'The suspended Sardaukar casualty allocation was changed.'); if (g.homeworldBattleLoss) requireRule(JSON.stringify(g.homeworldBattleLoss.commitment) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support}), 'The Homeworld Sardaukar casualties have lost their effective combat roles.'); } } battles.add(record.receipt.battle); turns.add(record.receipt.turn); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusSardaukarLast) === JSON.stringify({event: last.receipt.event, stage: last.stage}), 'The Nexus Sardaukar use has lost its latest saved outcome.'); if (last.receipt.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'), 'The Nexus Sardaukar use has lost its spent card.'); const record = currentNexusSardaukar(g), b = g.battle; requireRule((record?.stage === 'pending') === (responses.length > 0) && (last.stage !== 'pending' || record === last), 'The Nexus Sardaukar use has lost or reopened its response.'); if (b) requireRule(b.nexusSardaukarUsed === record?.receipt.event, 'The battle has lost its original Nexus Sardaukar marker.'); if (record) { requireRule(g.phase === 6 && record.receipt.turn === g.turn && b!.territory === record.receipt.territory && [b!.attacker, b!.defender].includes(record.receipt.owner), 'The temporary Sardaukar belong to another battle.'); if (record.stage !== 'canceled') { const p = getPlayer(g, record.receipt.owner), other = getPlayer(g, b!.attacker === p.id ? b!.defender : b!.attacker); const forces = combatForces(g, p, b!.territory, other); requireRule(forces.normal >= 5 && forces.elite === 0, 'The Nexus battle no longer has five eligible ordinary forces.'); } } for (const response of responses) validateNexusSardaukarResponse(g, response!); const lastLoss = history.find(record => record.receipt.battle === g.lastBattleContext?.event)?.casualties; const lossEvent = history.find(record => record.receipt.battle === g.lastBattleContext?.event)?.receipt.event; requireRule(g.lastBattleContext?.nexusSardaukarCasualties === (lastLoss ? lossEvent : undefined), 'The completed battle has lost its Nexus Sardaukar casualty receipt.'); } function nexusSardaukarEligibility(g: Game, p: Player): string | null { const b = g.battle; if (!g.advanced) return 'Sardaukar are an Advanced advantage.'; if (g.status !== 'playing' || g.phase !== 6 || !b?.event || ![b.attacker,b.defender].includes(p.id)) return 'Use Cunning in your own battle before submitting your Battle Plan.'; if (p.faction !== 'emperor' || p.ally || g.nexusCards?.cards?.hands[p.id] !== 'emperor') return 'Use your own Emperor Nexus card while unallied.'; if (b.revealed || b.plans[p.id] || b.nexusSardaukarUsed) return 'This battle has already committed your plan or used Emperor Cunning.'; const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const forces = combatForces(g, p, b.territory, other); if (forces.elite) return 'Cunning requires a battle containing none of your actual Sardaukar.'; if (forces.normal < 5) return 'Using Emperor Cunning with fewer than five eligible forces awaits a ruling.'; return null; } function projectedNexusSardaukar(g: Game, id: string) { if (!g.nexusCards?.cards) return null; const record = currentNexusSardaukar(g); const active = record?.stage === 'active'; const pending = record?.stage === 'pending' ? {event: record.receipt.event, owner: record.receipt.owner} : null; const p = getPlayer(g,id); if (p.faction !== 'emperor' || g.nexusCards.cards.hands[id] !== 'emperor') return {offer:null,active,pending}; let blocked = nexusSardaukarEligibility(g,p); if (!blocked && (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || pendingNexusTraitors(g) || g.nexusCards.phase?.stage === 'drawing')) blocked = 'Finish the current interaction before declaring Nexus Sardaukar.'; const event = g.battle?.event ? JSON.stringify(['nexusSardaukar',g.turn,g.battle.event,id]) : ''; if (!blocked) { const trial = structuredClone(g); commitNexusSardaukar(trial,getPlayer(trial,id),'active'); if (!findReachableBattlePlan(trial,getPlayer(trial,id))) blocked = 'Sardaukar strength would prevent you from honoring an existing Battle Plan commitment.'; } return {offer:{event,blocked},active,pending}; } function commitNexusSardaukar(g: Game, p: Player, stage: 'pending' | 'active') { const blocked = nexusSardaukarEligibility(g,p); requireRule(!blocked, blocked ?? 'The Nexus Sardaukar opportunity is unavailable.'); const b = g.battle!, other = getPlayer(g,b.attacker === p.id ? b.defender : b.attacker); const receipt = nexusRule(() => createNexusSardaukar(g,p.id,b.event!,b.territory,combatForces(g,p,b.territory,other).normal)); const record = {receipt,stage,parent:nexusSardaukarParent(g),signature:''}; record.signature = nexusSardaukarSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); (g.nexusSardaukarHistory ??= []).push(record); g.nexusSardaukarLast = {event:receipt.event,stage}; b.nexusSardaukarUsed = receipt.event; if (stage === 'pending') g.response = {kind:'nexusSardaukar',owner:p.id,intent:receipt.event,passed:[]}; } function playNexusSardaukar(g: Game,p: Player,action: Action) { const offer = projectedNexusSardaukar(g,p.id)?.offer; requireRule(Object.keys(action).every(key => ['type','event'].includes(key)) && offer && !offer.blocked && action.event === offer.event, offer?.blocked ?? 'Choose the current Emperor Cunning opportunity.'); commitNexusSardaukar(g,p,'pending'); log(g,`${p.name} spent Emperor Nexus Cunning to count five ordinary forces as Sardaukar for this battle. Karama may prevent this use. The physical counters remain ordinary forces.`, {faction:p.faction,name:'Nexus Sardaukar declared'}); } function finishNexusSardaukar(g: Game,canceled: boolean) { const record = currentNexusSardaukar(g)!; record.stage = canceled ? 'canceled' : 'active'; record.signature = nexusSardaukarSignature(record); g.nexusSardaukarLast = {event:record.receipt.event,stage:record.stage}; if (canceled) reconcileChangedBattleInspections(g, 'the canceled Sardaukar enhancement'); log(g,canceled ? 'Karama prevented Emperor Cunning. These forces remain ordinary in both strength and physical custody; the Nexus card stays spent.' : 'Five ordinary Emperor forces count as Sardaukar in this battle only. They use Sardaukar strength and support, including the Fremen exception, but every casualty remains an ordinary physical counter.', {faction:'emperor',name:canceled ? 'Nexus Sardaukar prevented' : 'Nexus Sardaukar active'}); } function battleInspectionContext(g: Game): BattleInspectionContext { const b = g.battle!; return { event: b.event!, attacker: b.attacker, defender: b.defender, players: g.players.map(({id,faction}) => ({id,faction})), native: b.prescience }; } function nexusInspectionIntegrity(g: Game) { const b = g.battle; if (!b) return; // Native Prescience is binding even when this battle used no Nexus card. // Check all sealed plans so removing a slot-only special cannot evade it. for (const target of [b.attacker, b.defender]) { const plan = b.plans[target]; if (plan) requireRule(committedPlanElements(b, target).every(element => plan[element.field] !== undefined && inspectedPlanMatches(getPlayer(g, target), element.field, element.value, plan[element.field])), 'The sealed plan contradicts its inspected commitment.'); } const record = b.nexusInspection; requireRule(!!record === !!b.nexusInspectionUsed, 'The Nexus inspection presence record is missing.'); if (!record) return; requireRule(g.phase === 6 && b.event && b.nexusInspectionUsed === b.event && g.nexusCards?.cards?.discard.includes('atreides'), 'The played Nexus inspection has lost its battle or physical card.'); nexusRule(() => validateNexusInspection(battleInspectionContext(g), record)); const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; }).filter(response => response?.kind === 'nexusPrescience'); requireRule((record.stage === 'response') === (responses.length > 0), 'The extra inspection has lost or reopened its original cancellation response.'); for (const response of responses) validateNexusInspectionResponse(g, response!); if (record.stage === 'answer' || record.stage === 'response') requireRule(!b.revealed && (b.preparation?.kind === 'nexusPrescienceAnswer' || (b.preparation?.kind === 'prescienceAnswer' && b.prescience && !Object.hasOwn(b.prescience, 'value'))), 'The unanswered Nexus inspection is missing its owned answer.'); if (b.preparation?.kind === 'nexusPrescienceAnswer') requireRule((record.stage === 'answer' || record.stage === 'response') && b.preparation.owner === record.target && b.preparation.beneficiary === record.owner, 'The pending Nexus answer belongs to the wrong player.'); } function nexusAtreidesOffer(g: Game, id: string) { if (g.nexusCards?.cards?.hands[id] !== 'atreides') return null; const p = getPlayer(g, id), b = g.battle; const mode = nexusCardMode('atreides', p.faction, g.players.map(player => player.faction)); let blocked: string | null = null; if (g.status !== 'playing' || g.phase !== 6 || !b?.event || b.revealed) blocked = 'Play this Nexus Card before the current battle plans are revealed.'; else if (p.ally) blocked = 'Allied players cannot use a Nexus Card.'; else if (b.nexusInspection || b.nexusInspectionUsed) blocked = 'This battle already used the Atreides Nexus Card.'; else if (pendingNexusTraitors(g)) blocked = 'Finish the private Nexus card return first.'; else if (g.truthtrance || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy) blocked = 'Finish the current interaction before playing this Nexus Card.'; else if (mode === 'betrayal') { blocked = 'Betrayal reactions await the private response timing decision.'; } else if (![b.attacker, b.defender].includes(id) || b.plans[id]) blocked = 'Use this inspection in your own battle before sealing your plan.'; else if (g.response || b.preparation || (b.preLeader && !b.preLeader.closed)) blocked = 'Finish the preceding battle preparation first.'; else if (mode === 'cunning' && (!b.prescience || b.prescience.player !== id || !Object.hasOwn(b.prescience, 'value'))) blocked = 'Cunning needs your first completed native inspection.'; const target = b && (b.attacker === id ? b.defender : b.attacker); const fields = mode === 'betrayal' ? [] : (['leader', 'weapon', 'defense', 'dial'] as PlanField[]) .filter(field => !(mode === 'cunning' && (field === b?.prescience?.field || (field === 'dial' && target && b?.noFieldPlayers?.includes(target))))); return { event: b?.event ?? '', mode, fields, blocked }; } function finishInspectionAnswers(g: Game) { const b = g.battle!; if (b.prescience && !Object.hasOwn(b.prescience, 'value')) { b.preparation = { kind: 'prescienceAnswer', owner: b.prescience.player === b.attacker ? b.defender : b.attacker, beneficiary: b.prescience.player }; } else if (b.nexusInspection?.stage === 'answer') { b.preparation = { kind: 'nexusPrescienceAnswer', owner: b.nexusInspection.target, beneficiary: b.nexusInspection.owner }; } else if (b.nexusInspection && b.nexusInspection.mode !== 'betrayal') delete b.preparation; else finishBattlePreparation(g); } function answerCurrentNexusInspection(g: Game, value: unknown) { const b = g.battle!, record = b.nexusInspection!; const target = getPlayer(g, record.target); requireRule(feasiblePrescience(g, target, record.field, value), 'Choose an element that permits a legal battle plan and respects every prior commitment.'); b.nexusInspection = nexusRule(() => answerNexusInspection(battleInspectionContext(g), record, value as string | number | null)); log(g, `${target.name} committed the requested ${record.field}. Its value is private to the two combatants.`); finishInspectionAnswers(g); } function validateNexusInspectionResponse(g: Game, response: ResponseWindow) { const b = g.battle, record = b?.nexusInspection; requireRule(response.kind === 'nexusPrescience' && record?.mode === 'cunning' && record.stage === 'response' && !record.answers.length && response.owner === record.owner && response.intent === b!.event, 'The extra Prescience response no longer matches its original attempt.'); return record; } function settleNexusInspectionResponse(g: Game, response: ResponseWindow, canceled: boolean) { const b = g.battle!, record = validateNexusInspectionResponse(g, response); if (canceled) { b!.nexusInspection = nexusRule(() => cancelNexusInspection(battleInspectionContext(g), record)); log(g, 'Karama canceled the additional Atreides inspection. The first disclosed element remains binding.'); finishInspectionAnswers(g); } else { b.nexusInspection = nexusRule(() => allowNexusInspection(battleInspectionContext(g), record)); if (b.plans[record.target]) answerCurrentNexusInspection(g, inspectedPlanValue(getPlayer(g, record.target), record.field, b.plans[record.target][record.field])); } } function playNexusAtreides(g: Game, p: Player, action: Action) { const offer = nexusAtreidesOffer(g, p.id); requireRule(offer && !offer.blocked && action.event === offer.event && action.mode === offer.mode, offer?.blocked ?? 'This Atreides Nexus opportunity is stale.'); requireRule(Object.keys(action).sort().join(',') === (offer.mode === 'betrayal' ? 'event,mode,type' : 'event,field,mode,type') && (offer.mode === 'betrayal' || offer.fields.includes(action.field as PlanField)), 'Choose a current Nexus inspection field.'); const b = g.battle!; const field = offer.mode === 'betrayal' ? b.prescience!.field : action.field as PlanField; const beneficiary = offer.mode === 'betrayal' ? b.prescience!.player : p.id; const target = beneficiary === b.attacker ? b.defender : b.attacker; const record = nexusRule(() => createNexusInspection(battleInspectionContext(g), { mode: offer.mode, owner: p.id, target, field })); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); b.nexusInspection = record; b.nexusInspectionUsed = b.event; log(g, `${p.name} played the Atreides Nexus Card: ${offer.mode === 'cunning' ? 'Cunning' : offer.mode === 'secretAlly' ? 'Secret Ally' : 'Betrayal'}.`, { faction: p.faction, name: 'Atreides Nexus' }); if (offer.mode === 'betrayal') { g.response = null; delete b.prescience; finishBattlePreparation(g); } else { b.preparation = { kind: 'nexusPrescienceAnswer', owner: target, beneficiary: p.id }; if (offer.mode === 'cunning') g.response = { kind: 'nexusPrescience', owner: p.id, intent: b.event, passed: [] }; else if (b.plans[target]) answerCurrentNexusInspection(g, inspectedPlanValue(getPlayer(g, target), field, b.plans[target][field])); } } function projectedNexusInsights(g: Game, id: string) { const record = g.battle?.nexusInspection; if (!record || ![record.owner, record.target].includes(id)) return []; return record.answers.map((answer, index) => { const value = inspectedPlanValue(getPlayer(g, record.target), record.field, answer); return { field: record.field, value, label: g.players.flatMap(p => [...p.leaders, ...p.hand]).concat(g.dukeVidal ? [g.dukeVidal.leader] : []) .find(item => item.id === value)?.name ?? String(value ?? 'None'), active: record.stage === 'answered' && index === record.answers.length - 1 }; }); } function homeworldRule(quote: () => T): T { try { return quote(); } catch (error) { if ( error instanceof HomeworldCustodyError || error instanceof EmperorHomeworldMoveError || error instanceof NativeReserveError || error instanceof EcazPoisonIncomeError || error instanceof HomeworldBenefitError ) throw new RuleError(error.message); throw error; } } function stringField(value: unknown) { requireRule(typeof value === 'string', 'Expected a string.'); return value; } function requireRule(condition: unknown, message: string): asserts condition { if (!condition) throw new RuleError(message); } export function integer( value: unknown, min: number, max: number, label = 'Amount', ): number { requireRule( typeof value === 'number' && Number.isSafeInteger(value) && value >= min && value <= max, `${label} must be an integer from ${min} to ${max}.`, ); return value; } const random = () => crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296; export function shuffle(items: T[], rng = random): T[] { const a = [...items]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } const log = ( g: Game, text: string, automatic?: { faction: FactionId; name: string }, component?: StormCardComponent, ) => { g.log.push({ seq: (g.log.at(-1)?.seq ?? 0) + 1, text, ...(automatic ? { automatic } : {}), ...(component ? { component } : {}), }); if (g.log.length > 250) g.log.shift(); }; const getPlayer = (g: Game, id: string) => { const p = g.players.find((p) => p.id === id); requireRule(p, 'You are not seated at this table.'); return p; }; const byFaction = (g: Game, f: string) => g.players.find((p) => p.faction === f); const at = presenceAt; function noFieldRule(operation: () => T): T { try { return operation(); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid No-Field operation.', ); } } function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) { requireRule( p.faction === 'richese' && p.noField?.deployed, 'No concealed No-Field is deployed.', ); const result = noFieldRule(() => revealRicheseNoField(p.noField!, { tokenId: p.noField!.deployed!.tokenId, reserves: p.reserves, cause, }), ); requireRule( result.controller === p.id, 'This concealed token has an invalid controller.', ); const cohort = g.ornithopter?.player === p.id ? g.ornithopter.cohort : undefined; if ( cohort?.noField?.tokenId === p.noField!.deployed!.tokenId && cohort.noField.event === p.noFieldEvent ) { const key = location(result.location.territory, result.location.sector); cohort.forces[key] = (cohort.forces[key] ?? 0) + result.forces; delete cohort.noField; } p.noField = result.state; p.noFieldEvent = crypto.randomUUID(); p.reserves -= result.forces; if (result.forces) place(p, result.location.territory, result.location.sector, result.forces); observeOccupation(g); log( g, `${p.name} revealed the ${result.value} No-Field in ${territory(result.location.territory).name} (${cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`, { faction: 'richese', name: 'No-Field revealed' }, ); return result; } function noFieldShipBlock(g: Game, p: Player): string | null { if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || p.shipped) return 'Use a No-Field during your shipment, before movement.'; if (g.truthtrance || g.phaseOpening || g.response || g.decision) return 'Resolve the pending decision before declaring a No-Field shipment.'; if (p.noFieldBlockedTurn === g.turn) return 'Karama prevented No-Field use for this shipment opportunity. Normal shipment remains available.'; if (!p.noField || !p.noFieldEvent) return 'No-Field inventory is not initialized in this saved table.'; if (p.noField.deployed) return 'Reveal the existing No-Field before shipping another.'; return null; } function noFieldRevealBlock(g: Game, p: Player): string | null { if (g.status !== 'playing' || g.phase >= 6) return 'Voluntary No-Field reveal is available before the Battle phase.'; if (!p.noField?.deployed) return 'No concealed No-Field is deployed.'; if (g.truthtrance || g.phaseOpening || g.response || g.decision) return 'Resolve the pending interaction before revealing the No-Field.'; return null; } function noFieldAllyOfferBlock( g: Game, owner: Player, pending = false, ): string | null { const ally = g.players.find((p) => p.id === owner.ally); if (owner.faction !== 'richese' || !ally || ally.ally !== owner.id) return 'Richese must have a current mutual ally.'; if ( g.status !== 'playing' || g.phase !== 5 || g.active !== ally.id || ally.shipped ) return 'Offer the token during your ally’s unused shipment turn.'; if ( !pending && (g.truthtrance || g.phaseOpening || g.response || (g.decision && !( g.decision.kind === 'richeseAllyOpportunity' && g.decision.player === owner.id ))) ) return 'Resolve the pending interaction before offering allied shipment.'; if (!owner.noField || !owner.noFieldEvent) return 'No-Field inventory is not initialized.'; if (ally.faction === 'fremen') return 'Fremen reserves are on the planet; this ability ships off-planet reserves.'; if (ally.faction === 'guild' || g.karamaShipping?.player === ally.id) return 'Allied No-Field pricing with Guild or Karama discounts awaits a ruling.'; if ( g.richeseAllyBlocked?.turn === g.turn && g.richeseAllyBlocked.recipient === ally.id ) return 'Karama prevented No-Field use for this allied shipment opportunity.'; return null; } function alliedNoFieldQuote( g: Game, offer: RicheseAllyOffer, checkFunds = true, ) { const owner = getPlayer(g, offer.owner), recipient = getPlayer(g, offer.recipient); const blocked = noFieldAllyOfferBlock(g, owner, true); requireRule(!blocked, blocked ?? 'Allied No-Field shipment is unavailable.'); requireRule( owner.ally === recipient.id && owner.noFieldEvent === offer.tokenEvent, 'This allied No-Field offer is stale.', ); requireRule( offer.payer === owner.id || offer.payer === recipient.id || offer.payer === 'both', 'Choose an ally payer or split a two-spice shipment equally.', ); requireRule( offer.territory !== MOBILE_STRONGHOLD && validGameLocation(g, offer.territory, offer.sector), 'Choose a printed planet sector for the allied No-Field shipment.', ); // Test the compulsory old-marker reveal without exposing or mutating it before acceptance. const trial = structuredClone(g), trialOwner = getPlayer(trial, owner.id), trialRecipient = getPlayer(trial, recipient.id); if (trialOwner.noField!.deployed) revealPlayerNoField(trial, trialOwner, 'beforeAlly'); const materialized = noFieldRule(() => shipAlliedRicheseNoField( trialOwner.noField!, { tokenId: offer.tokenId, controller: recipient.id, location: { territory: offer.territory, sector: offer.sector }, }, trialRecipient.reserves, ), ); const advisors = arrivalAsAdvisor(trial, trialRecipient, offer.territory); allowedEntry( trial, trialRecipient, offer.territory, offer.sector, false, advisors, ); const cost = territory(offer.territory).type === 'stronghold' ? 1 : 2; requireRule( offer.payer !== 'both' || cost === 2, 'An equal split requires the two-spice shipment price.', ); const ownerPayment = offer.payer === owner.id ? cost : offer.payer === 'both' ? 1 : 0; const recipientPayment = cost - ownerPayment; // The supplier may authorize only its own spending; recipient funding stays private until consent. requireRule( uncommittedSpice(g, owner) >= ownerPayment, 'Richese needs enough uncommitted spice for its payment.', ); if (checkFunds) requireRule( uncommittedSpice(g, recipient) >= recipientPayment, 'The recipient needs enough uncommitted spice for its payment.', ); const elites = recipient.elites?.reserves ?? 0; return { cost, ownerPayment, recipientPayment, amount: materialized.forces, value: materialized.value, advisors, eliteMin: Math.max(0, materialized.forces - (recipient.reserves - elites)), eliteMax: Math.min(elites, materialized.forces), }; } function giftReserved(g: Game, owner: string, card: string) { return ( g.pendingRicheseGift?.intent.owner === owner && g.pendingRicheseGift.intent.cardId === card ); } function nullentropyContext(g: Game) { const trial = structuredClone(g), pending = trial.pendingNullentropy; if (pending) { trial.response = pending.resume.response; trial.decision = pending.resume.decision; trial.pendingKarama = pending.resume.pendingKarama; trial.phaseOpening = pending.resume.phaseOpening; trial.pendingNullentropy = null; } return trial; } function nullentropyIntegrity(g: Game): string | null { const pending = g.pendingNullentropy; if ( !pending || pending.turn !== g.turn || pending.phase !== g.phase || g.decision?.kind !== 'nullentropy' || g.decision.player !== pending.player || g.response || g.phaseOpening || g.pendingKarama ) return 'The saved private search is inconsistent; restore its saved table state before continuing.'; const owner = g.players.find((p) => p.id === pending.player); if ( !owner || owner.hand.filter( (c) => c.id === pending.box && richeseCardDefinition(c)?.card.effect === 'nullentropyBox', ).length !== 1 || JSON.stringify(g.discard) !== pending.discardSignature || JSON.stringify(g.discard.map((c) => c.id)) !== JSON.stringify(pending.discardIds) ) return 'The saved search card custody changed; restore its saved table state before continuing.'; return null; } function validateNullentropyBegin(g: Game, owner: Player, cardId: string) { requireRule( g.status === 'playing', 'Nullentropy Box is available during an active game.', ); requireRule( !g.pendingNullentropy, 'Finish the paid Nullentropy Box search first.', ); requireRule( !g.truthtrance, 'Resolve the active Truthtrance question before searching the discard.', ); const card = owner.hand.find((c) => c.id === cardId); requireRule( card && richeseCardDefinition(card)?.card.effect === 'nullentropyBox', 'Use the canonical Nullentropy Box physically in your hand.', ); const reserved = transferCardBlock(g, owner, card); requireRule(!reserved, reserved ?? 'The Box is already committed.'); requireRule( owner.hand.length < handLimit(owner), 'Full-hand Nullentropy Box use is unresolved; the current guard requires a pre-existing free hand slot.', ); const guild = byFaction(g, 'guild'); requireRule( !( g.karamaShipping?.card && guild && !guild.specialKaramaUsed && g.discard.some((c) => c.id === g.karamaShipping?.card) ), 'Searching while a shipping Karama has a provisional Guild refund claim awaits resolution.', ); // A completed purchase has already paid its bid; it is no longer a reservation. const funding = structuredClone(g); if (funding.currentAuctionSale) { funding.auction = null; funding.richeseAuction = null; } requireRule( uncommittedSpice(funding, getPlayer(funding, owner.id)) >= 2, 'You need two uncommitted spice to pay the bank for this search.', ); const paid = structuredClone(g); getPlayer(paid, owner.id).spice -= 2; reconcileBattlePromises(paid, { actor: owner.id, action: { type: 'card', card: cardId }, }); let candidates: Card[]; try { candidates = eligibleNullentropyCards(g.discard); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid discard custody.', ); } requireRule( candidates.length > 0, 'An empty or only-Box discard search is unsupported; no spice has been paid.', ); return { card, candidates }; } function finishNullentropy( g: Game, player: string, event: string, cardId: string, ) { const pending = g.pendingNullentropy; requireRule( pending && pending.player === player, 'Only the paid search owner can select its card.', ); requireRule(pending.event === event, 'This private search event is stale.'); const invalid = nullentropyIntegrity(g); requireRule(!invalid, invalid ?? 'The search cannot continue.'); const owner = getPlayer(g, player), context = nullentropyContext(g); const remaining = g.discard.filter((c) => c.id !== cardId); let preview: ReturnType; try { preview = resolveNullentropyBox( owner.hand, pending.box, g.discard, cardId, remaining.map((c) => c.id), handLimit(owner), ); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid private search selection.', ); } validateTransferCompletion( context, getPlayer(context, player), getPlayer(context, player), preview.ownerHand, preview.ownerHand, { type: 'card', card: pending.box }, ); // All rejection paths precede this single server shuffle. The paid record and // final shuffled order are each persisted by the room's normal CAS fence. const order = shuffle(remaining).map((c) => c.id); const result = resolveNullentropyBox( owner.hand, pending.box, g.discard, cardId, order, handLimit(owner), ); owner.hand = result.ownerHand; g.discard = result.discard; g.pendingNullentropy = null; g.decision = null; log( g, `${owner.name} recovered one private card with Nullentropy Box, shuffled the remaining discard pile, and placed the used Box on top. The bank was paid when the search began.`, { faction: owner.faction, name: 'Nullentropy Box' }, ); stageTreacheryDiscard( g, 'nullentropyBox', [{ card: result.discard.at(-1)!, discardedBy: player, publicFace: true }], { kind: 'nullentropyDiscard', player, searchEvent: event, box: pending.box, selected: result.selected, finalDiscardIds: g.discard.map((c) => c.id), finalDiscardSignature: JSON.stringify(g.discard), resume: pending.resume, parentSignature: nullentropyParentSignature(g, pending.resume, event), }, ); } function beginNullentropy(g: Game, owner: Player, cardId: string) { const { candidates } = validateNullentropyBegin(g, owner, cardId); owner.spice -= 2; const event = crypto.randomUUID(); g.pendingNullentropy = { event, player: owner.id, box: cardId, turn: g.turn, phase: g.phase, discardIds: g.discard.map((c) => c.id), discardSignature: JSON.stringify(g.discard), resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening, }, }; g.response = null; g.pendingKarama = null; g.phaseOpening = null; g.decision = { kind: 'nullentropy', player: owner.id }; log( g, `${owner.name} paid two spice to the bank to privately search the discard pile with Nullentropy Box. The interrupted decision waits for this search.`, ); if (candidates.length === 1) finishNullentropy(g, owner.id, event, candidates[0].id); } function nullentropyView(g: Game, owner: Player) { const pending = g.pendingNullentropy; const card = owner.hand.find( (c) => richeseCardDefinition(c)?.card.effect === 'nullentropyBox', ); if (!card) return null; if (pending?.player === owner.id) { const blocked = nullentropyIntegrity(g); return { card, blocked, search: blocked ? null : { event: pending.event, cards: eligibleNullentropyCards(g.discard).sort( (a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id), ), }, }; } let blocked: string | null = null; try { validateNullentropyBegin(g, owner, card.id); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; } return { card, blocked, search: null }; } function savedTransferResponses(g: Game) { const canceled = (pending: Game['pendingKarama']) => pending?.use.kind === 'cancel' ? pending.use.response : null; return [ g.response, canceled(g.pendingKarama), g.pendingExchange?.response, g.pendingRicheseGift?.resume.response, canceled(g.pendingRicheseGift?.resume.pendingKarama), ]; } function transferCardBlock(g: Game, owner: Player, card: Card) { if (g.pendingIxRicheseTechnology?.owner === owner.id && g.pendingIxRicheseTechnology.card.id === card.id) return 'This card is reserved for the pending Richese lot and Ixian choice.'; if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id)) return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.'; if ( g.pendingNullentropy?.player === owner.id && g.pendingNullentropy.box === card.id ) return 'This Box is reserved for the paid private search.'; const reserved = ambassadorDiscardBlock(g, owner, card); if (reserved) return reserved; if ( g.richeseAuction?.source === 'blackMarket' && !g.currentAuctionSale && g.richeseAuction.owner === owner.id && g.richeseAuction.cardId === card.id ) return 'The card offered for Black Market must remain available.'; if ( g.pendingIxAlly?.player === owner.id && g.pendingIxAlly.card === card.id && savedTransferResponses(g).some( (response) => response?.kind === 'ixAllyCard', ) ) return 'The card committed to Ixian replacement must remain available.'; return null; } function validateTransferCompletion( g: Game, owner: Player, recipient: Player, ownerHand: Card[], recipientHand: Card[], action: Action, ) { const pendingKaramaPurchase = savedKaramaConversions(g).some( (pending) => pending.owner === recipient.id && ['purchase', 'auctionPayment'].includes(pending.use.kind), ); const lot = g.richeseAuction; const richeseReserved = lot && (lot.outcome ? lot.outcome.kind === 'sold' && lot.outcome.winner === recipient.id : lot.method === 'silent' ? Object.hasOwn(lot.sealed, recipient.id) : lot.bidder === recipient.id); const auctionSlot = Number( !g.currentAuctionSale && !!( g.auction?.bidder === recipient.id || pendingKaramaPurchase || richeseReserved ), ); if (recipientHand.length >= handLimit(recipient) && !g.currentAuctionSale) { requireRule( g.auction?.bidder !== recipient.id, 'Leave room for the recipient’s committed auction purchase.', ); requireRule( !pendingKaramaPurchase, 'Leave room for the recipient’s pending Karama auction purchase.', ); requireRule( !richeseReserved, 'Leave room until the recipient’s auction commitment is resolved; sealed submissions reserve the slot without revealing their bid.', ); } const trial = structuredClone(g), donor = getPlayer(trial, owner.id); donor.hand = ownerHand; getPlayer(trial, recipient.id).hand = recipientHand; const decisions = [ g.decision, g.pendingExchange?.decision, g.pendingRicheseGift?.resume.decision, ]; for (const exchange of decisions) if (exchange?.kind === 'handExchange') { requireRule( exchange.player !== owner.id || ownerHand.length >= exchange.count, 'Keep enough cards to complete your committed hand exchange.', ); requireRule( exchange.target !== recipient.id || recipientHand.length + exchange.count + auctionSlot <= handLimit(recipient), 'Leave room for the cards the recipient must receive from the committed hand exchange and any pending auction purchase.', ); } const pending = trial.pendingAmbassador; if (pending?.beneficiary === owner.id) { const entrant = getPlayer(trial, pending.entrant); if (pending.stage === 'cards' && pending.effect === 'ixians') requireRule( !ambassadorEffectBlock(trial, 'ixians', donor, entrant), 'Keep a discardable card for the committed Ixian Ambassador exchange.', ); if (pending.stage === 'copy') requireRule( pending.copyChoices.some( (effect) => !ambassadorEffectBlock(trial, effect, donor, entrant), ), 'Keep an available effect for the committed Ambassador copy.', ); } reconcileBattlePromises(trial, { actor: owner.id, action }); } function distransBlock(g: Game, owner: Player, card: Card): string | null { if (g.pendingNullentropy) return 'Finish the paid Nullentropy Box search first.'; if (g.status !== 'playing') return 'Distrans is available during an active game.'; if (g.truthtrance) return 'Resolve the active Truthtrance question before changing hands.'; const reserved = transferCardBlock(g, owner, card); if (reserved) return reserved; // The face excludes a bid, not the complete Bidding phase. A live unpaid lot // remains guarded pending the requested ruling; already-paid income is separate. if ((g.auction || g.richeseAuction) && !g.currentAuctionSale) return 'Distrans during an unresolved auction lot awaits a ruling. It remains available outside that bid.'; return null; } function validateDistrans( g: Game, owner: Player, cardId: string, recipientId: string, giveId: string, ) { const card = owner.hand.find((c) => c.id === cardId); requireRule( card && richeseCardDefinition(card)?.card.effect === 'distrans', 'Play the canonical Distrans physically in your hand.', ); const blocked = distransBlock(g, owner, card); requireRule(!blocked, blocked ?? 'Distrans is unavailable.'); const recipient = getPlayer(g, recipientId); let result: ReturnType; try { result = transferDistrans( owner, recipient, cardId, giveId, handLimit(recipient), ); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Distrans transfer.', ); } const reserved = transferCardBlock(g, owner, result.transferred); requireRule(!reserved, reserved ?? 'This card is already committed.'); validateTransferCompletion( g, owner, recipient, result.ownerHand, result.recipientHand, { type: 'card', card: cardId, target: recipientId, give: giveId }, ); return { recipient, result }; } function distransView(g: Game, owner: Player) { const card = owner.hand.find( (c) => richeseCardDefinition(c)?.card.effect === 'distrans', ); if (!card) return null; const blocked = distransBlock(g, owner, card); const choices = g.players .filter((p) => p.id !== owner.id) .map((recipient) => { const cards: Card[] = [], unavailable: { card: Card; reason: string }[] = []; const targetBlock = blocked ?? (recipient.hand.length >= handLimit(recipient) ? 'The recipient’s Treachery hand is full.' : null); for (const given of owner.hand.filter((c) => c.id !== card.id)) { if (targetBlock) { unavailable.push({ card: given, reason: targetBlock }); continue; } try { validateDistrans(g, owner, card.id, recipient.id, given.id); cards.push(given); } catch (error) { if (!(error instanceof RuleError)) throw error; unavailable.push({ card: given, reason: error.message }); } } return { recipient: recipient.id, blocked: targetBlock, cards, unavailable, }; }); return { card, blocked, choices }; } function richeseGiftBlock(g: Game, owner: Player): string | null { if (g.pendingNullentropy) return 'Finish the paid Nullentropy Box search first.'; if (g.status !== 'playing') return 'Richese gifts are available during an active game.'; if (owner.faction !== 'richese') return 'Only Richese can give a Richese card to its ally.'; if (g.truthtrance) return 'Resolve the active Truthtrance question before changing hands.'; if (g.pendingRicheseGift) return 'Resolve the pending Richese gift first.'; const ally = g.players.find((p) => p.id === owner.ally); if (!ally || ally.ally !== owner.id) return 'You need a current mutual ally.'; if (ally.hand.length >= handLimit(ally)) return 'Your ally’s Treachery hand is full.'; return null; } function richeseGiftContext(g: Game) { const context = structuredClone(g), pending = context.pendingRicheseGift; if (pending) { context.response = pending.resume.response; context.decision = pending.resume.decision; context.pendingKarama = pending.resume.pendingKarama; context.phaseOpening = pending.resume.phaseOpening; context.pendingRicheseGift = null; } return context; } function validateRicheseGift(g: Game, owner: Player, cardId: string) { const blocked = richeseGiftBlock(g, owner); requireRule(!blocked, blocked ?? 'This gift is unavailable.'); const recipient = getPlayer(g, owner.ally!); let intent: RicheseGiftIntent; try { intent = prepareRicheseGift(owner, recipient, cardId, handLimit(recipient)); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Richese gift.', ); } const card = owner.hand.find((c) => c.id === cardId)!; const reservation = transferCardBlock(g, owner, card); requireRule(!reservation, reservation ?? 'This card is already committed.'); requireRule( !( g.richeseGiftBlocked?.turn === g.turn && g.richeseGiftBlocked.phase === g.phase && g.richeseGiftBlocked.cards.includes(cardId) ), 'Retrying this canceled card’s gift in the same phase awaits a ruling.', ); const resulting = transferRicheseGift( owner, recipient, intent, handLimit(recipient), ); validateTransferCompletion( g, owner, recipient, resulting.ownerHand, resulting.recipientHand, { type: 'richeseGift', card: cardId }, ); return { intent, resulting }; } function richeseGiftView(g: Game, viewer: Player) { const owner = byFaction(g, 'richese'); if (!owner) return null; const pending = g.pendingRicheseGift; if (viewer.id !== owner.id && pending?.intent.recipient !== viewer.id) return null; const mine = viewer.id === owner.id; const blocked = richeseGiftBlock(g, owner); const cards: Card[] = [], unavailable: { card: Card; reason: string }[] = []; if (mine) for (const card of owner.hand.filter((card) => richeseCardDefinition(card), )) { if (blocked) { unavailable.push({ card, reason: blocked }); continue; } try { validateRicheseGift(g, owner, card.id); cards.push(card); } catch (error) { if (!(error instanceof RuleError)) throw error; unavailable.push({ card, reason: error.message }); } } return { owner: owner.id, recipient: owner.ally, blocked, cards, unavailable, pending: pending ? { event: pending.event, owner: pending.intent.owner, recipient: pending.intent.recipient, card: getPlayer(g, pending.intent.owner).hand.find( (c) => c.id === pending.intent.cardId, ) ?? null, } : null, }; } export const handLimit = ( p: Pick & Partial>, ) => (p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4) - Number(p.atomicsHandLimitPenalty === true); function atomicsRule(run: () => T): T { try { return run(); } catch (error) { if (error instanceof Error) throw new RuleError(error.message); throw error; } } /** Only the clean printed-territory entry is connected; other arrival parents remain gated. */ function atomicsModeSupported(g: Game): boolean { return !!byFaction(g, 'moritani') && !byFaction(g, 'ecaz') && !byFaction(g, 'guild') && g.players.every(p => CLASSIC_FACTIONS[p.faction] === true || p.faction === 'moritani') && g.expansions.every(expansion => expansion === 'ecaz') && !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.pendingArrivalOverlap; } function currentAtomicsQuote( g: Game, entry: NonNullable, ): AtomicsQuote { requireRule(atomicsModeSupported(g) && !g.moritaniAtomics && !g.moritaniExtortion, 'Atomics with these combined modules or another unresolved Terror effect is not yet supported.'); const token = g.moritaniTerror?.tokens.find(row => row.id === entry.token); requireRule(token, 'The Atomics token has lost its original stronghold.'); return atomicsRule(() => quoteMoritaniAtomics({ token, territory: entry.territory, moritaniId: byFaction(g, 'moritani')!.id, turn: g.turn, aftermath: g.moritaniAtomics ?? null, players: g.players.map(player => ({ ...player, handSize: player.hand.length, baseHandLimit: handLimit(player), })), })); } function resolveAtomics( g: Game, entry: NonNullable, quote: AtomicsQuote, ) { g.moritaniAtomics = quote.aftermath; for (const casualty of quote.casualties) { const player = getPlayer(g, casualty.playerId); kill(g, player, casualty.location, casualty.normal + casualty.elite, false, casualty.elite); } for (const player of g.players) if (!at(player, quote.aftermath.territory) && player.advisors) delete player.advisors[quote.aftermath.territory]; observeOccupation(g); const handBefore: { player: string; count: number }[] = []; const entries: FreshDiscardBatch['entries'][number][] = []; for (const reduction of quote.handReductions) { const player = getPlayer(g, reduction.playerId); handBefore.push({ player: player.id, count: player.hand.length }); player.atomicsHandLimitPenalty = true; requireRule(handLimit(player) === reduction.limit, 'Atomics has lost its original hand-limit reduction.'); for (let i = 0; i < reduction.randomDiscards; i++) { const card = player.hand[Math.floor(random() * player.hand.length)]; entries.push({ card: discard(g, player, card.id), discardedBy: player.id, publicFace: false }); } } log(g, `Atomics destroyed every force in ${territory(quote.aftermath.territory).name}. Aftermath permanently blocks shipments there, including Fremen reinforcements. ${quote.aftermath.allyAtActivation ? 'Moritani and its ally each lost' : 'Moritani lost'} one Treachery hand slot; ${entries.length} excess ${entries.length === 1 ? 'card was' : 'cards were'} discarded at random.`); if (entries.length) { g.pendingTerrorEntry = null; g.decision = null; stageTreacheryDiscard(g, 'terror:atomics', entries, { kind: 'terrorAtomics', entry, aftermath: quote.aftermath, handBefore }); } else finishTerrorEntry(g); } export function newPlayer(id: string, name: string, f: FactionId): Player { requireRule( name.trim().length > 0 && name.trim().length <= 32, 'Enter a name of 1–32 characters.', ); requireRule( FACTIONS.some((x) => x.id === f), 'Choose a valid faction.', ); return { id, name: name.trim(), faction: f, ready: false, spice: 0, reserves: 20, tanks: 0, forces: {}, ...(f === 'ixians' ? { elites: { reserves: 7, tanks: 0, forces: {}, revived: 0 } } : {}), hand: [], leaders: leaders(f), traitors: [], traitorChoices: [], ally: null, bribes: 0, revived: 0, freeForcesRevived: 0, leaderRevived: false, revivalCycle: 0, shipped: false, moved: 0, battleLosses: 0, }; } export function createGame( code: string, host: Player, advanced = false, expansions: string[] = [], ): Game { requireRule( expansions.every((x) => ['ix', 'choam', 'ecaz'].includes(x)), 'Unknown expansion.', ); return { schema: 1, code, version: 0, host: host.id, status: 'lobby', advanced, expansions: [...new Set(expansions)], players: [host], playerPositions: { [host.id]: 1 }, turn: 1, phase: 0, storm: STORM_START_SECTOR, order: [], active: null, movementRemaining: null, guildTimingGranted: false, guildTimingLocked: false, ready: [], deck: [], discard: [], spiceDeck: [], spicePeekKnown: false, spiceDiscard: [[], []], spice: {}, log: [{ seq: 1, text: `${host.name} opened the table.` }], stormDials: {}, stormPending: null, stormCard: null, stormCardKnown: false, stormResolution: null, karamaShipping: null, stormDialers: [], lastBattle: [], auction: null, battle: null, nexus: false, allianceOffers: {}, winner: [], shieldWallDestroyed: false, spiceWindow: null, hajr: [], decision: null, pendingCapture: null, response: null, wormRides: [], wormPlacementCanceledTurn: 0, spiceResolution: null, spiceSequence: null, freeRevival: [], emperorExtra: {}, aid: {}, }; } export function joinGame(g: Game, p: Player) { requireRule(g.status === 'lobby', 'This game has already started.'); requireRule(g.players.length < 6, 'This table has six players.'); requireRule(!g.players.some((x) => x.id === p.id), 'Already seated.'); requireRule( !g.players.some((x) => x.faction === p.faction), 'That faction is already taken.', ); requireRule( faction(p.faction).expansion === 'base' || g.expansions.includes(faction(p.faction).expansion), 'The host has not enabled that expansion.', ); g.players.push(p); g.playerPositions = normalizedPlayerPositions(g); g.players.forEach((x) => (x.ready = !!x.bot)); log(g, `${p.name} joined as ${faction(p.faction).name}.`); } function draw(g: Game) { requireRule( !g.pendingTreacheryDiscard, 'Finish the committed discard continuation before drawing cards.', ); requireRule( !g.pendingNullentropy, 'Finish the paid Nullentropy Box search before drawing cards.', ); if (!g.deck.length) { g.deck = shuffle(g.discard); g.discard = []; } return g.deck.shift(); } function discard(g: Game, p: Player, id: string, role?: Omit) { requireRule(g.pendingIxRicheseTechnology?.card.id !== id, 'The card reserved for the pending Richese lot cannot be discarded.'); requireRule( !g.pendingTreacheryDiscard, 'Finish the committed discard continuation before another discard.', ); requireRule( !g.pendingNullentropy, 'Finish the paid Nullentropy Box search before changing the discard pile.', ); requireRule( !giftReserved(g, p.id, id), 'This card is reserved for the pending Richese gift.', ); const c = p.hand.find((c) => c.id === id); requireRule(c, 'Card is not in your hand.'); const income = homeworldRule(() => quoteEcazPoisonIncome(g, [{ card: c, ...role }])); if (income) { const ecaz = getPlayer(g, income.player); requireRule(Number.isSafeInteger(ecaz.spice + income.amount), 'Ecaz income would exceed a valid spice balance.'); ecaz.spice += income.amount; // Discard category and resulting private spice must not enter the public // chronicle. The native owner receives a private income receipt instead. (g.ecazPoisonIncome ??= []).push({ ...income, turn: g.turn, phase: g.phase }); } g.discard.push(c); p.hand = p.hand.filter((c) => c.id !== id); return c; } function battleDiscardRoles(g: Game, battle: Battle) { const players = [getPlayer(g, battle.attacker), getPlayer(g, battle.defender)]; const selected = players.map((p) => ({ weapon: cardOf(p, battle.plans[p.id].weapon), defense: cardOf(p, battle.plans[p.id].defense) })); const weapons = resolveBattleWeapons({ attacker: selected[0], defender: selected[1] }); requireRule(!weapons.error, weapons.error ?? 'Invalid effective battle weapons.'); const roles: NonNullable['cardRoles']> = {}; for (const [index, p] of players.entries()) { roles[p.id] = {}; for (const slot of ['weapon', 'defense', 'leader'] as const) { const id = battle.plans[p.id][slot]; if (!id || !cardOf(p, id)) continue; roles[p.id][id] = { battleSlot: slot, ...(slot === 'weapon' ? { effectiveWeapon: index === 0 ? weapons.attacker : weapons.defender } : {}) }; } } return roles; } function cleanupDiscardRole(g: Game, player: Player, id: string) { battleCardRolesIntegrity(g); const card = cardOf(player, id)!; const context = g.lastBattleContext; const role = context?.cardRoles?.[player.id]?.[id]; if (g.homeworlds?.custody && byFaction(g, 'ecaz') && (card.kind === 'chemistry' || card.effect === 'mirrorWeapon')) requireRule(context?.turn === g.turn && context.combatants.includes(player.id) && role, 'This conditional weapon cleanup needs its original battle role receipt.'); return role; } function battleCardRolesSignature(context: NonNullable) { const { event, turn, territory, combatants, winner, result, cardRoles } = context; return JSON.stringify({ event, turn, territory, combatants, winner, result, cardRoles }); } function battleMandatoryDiscardSignature(context: NonNullable) { return JSON.stringify({ event: context.event, turn: context.turn, territory: context.territory, combatants: context.combatants, winner: context.winner, result: context.result, entries: context.mandatoryDiscard?.entries, }); } function battleCardRolesIntegrity(g: Game) { const context = g.lastBattleContext; if (!context?.cardRoles && !context?.cardRolesSignature) return; requireRule(context.cardRoles && context.cardRolesSignature === battleCardRolesSignature(context), 'The saved battle card roles no longer match their resolved battle receipt.'); for (const [player, cards] of Object.entries(context.cardRoles)) { requireRule(context.combatants.includes(player) && cards && !Array.isArray(cards), 'The resolved battle has an invalid card-role owner.'); const slots = Object.values(cards).map((role) => role?.battleSlot); requireRule(new Set(slots).size === slots.length && slots.every((slot) => ['weapon', 'defense', 'leader'].includes(slot ?? '')), 'The resolved battle card slots are missing or duplicated.'); for (const [card, role] of Object.entries(cards)) requireRule(role.battleSlot === 'weapon' ? role.effectiveWeapon?.physicalId === card : role.effectiveWeapon === undefined, 'The resolved battle weapon descriptor does not match its physical slot.'); } } /** A receipt of suspended controls and live pending obligations; never restored * as game state. Player resources, hands, log and seat control are excluded. */ function nullentropyParentSignature( g: Game, resume: NonNullable['resume'], searchEvent: string, ) { const parents = Object.fromEntries( Object.entries(g) .filter( ([key, value]) => key.startsWith('pending') && ![ 'pendingTreacheryDiscard', 'pendingNullentropy', 'pendingKarama', ].includes(key) && value != null, ) .sort(([a], [b]) => a.localeCompare(b)), ); return JSON.stringify({ searchEvent, resume, parents }); } type OrdinaryDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'ordinaryCardDiscard' } >; /** Bind completed effects, not a replacement Game. Seat control and private hands * may change independently of these outcomes; neither is restored on recovery. */ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) { return JSON.stringify({ player: next.player, card: next.card, effect: next.effect, controls: nullentropyParentSignature( g, next.resume, `ordinary:${next.card}`, ), phase: g.phase, turn: g.turn, advanced: g.advanced, expansions: g.expansions, active: g.active, order: g.order, ready: g.ready, movementRemaining: g.movementRemaining, hajr: g.hajr, ornithopter: g.ornithopter, karamaShipping: g.karamaShipping, storm: g.storm, stormPending: g.stormPending, stormDials: g.stormDials, stormResolution: g.stormResolution, shieldWallDestroyed: g.shieldWallDestroyed, spice: g.spice, spiceWindow: g.spiceWindow, spiceResolution: g.spiceResolution, spiceSequence: g.spiceSequence, battle: g.battle, shipmentPromises: g.shipmentPromises, techTokens: g.techTokens, homeworlds: g.homeworlds, homeworldRevival: g.homeworldRevival, homeworldRevivalReturn: g.homeworldRevivalReturn, ...(g.homeworldRevivalProgress === undefined ? {} : { homeworldRevivalProgress: g.homeworldRevivalProgress }), homeworldVictoryReinforcement: g.homeworldVictoryReinforcement, ...(g.grummanCollection === undefined ? {} : { grummanCollection: g.grummanCollection }), dukeVidal: g.dukeVidal, revivalFreeIncome: g.revivalFreeIncome, players: g.players.map((p) => ({ id: p.id, faction: p.faction, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, advisors: p.advisors, moved: p.moved, shipped: p.shipped, revived: p.revived, revivalCycle: p.revivalCycle, leaderRevived: p.leaderRevived, leaders: p.leaders, kwisatz: p.kwisatz, })), }); } function stageOrdinaryCardDiscard(g: Game, p: Player, card: Card) { const resume = { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening, }; g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; const next: OrdinaryDiscardContinuation = { kind: 'ordinaryCardDiscard', player: p.id, card: card.id, effect: card.effect as OrdinaryDiscardContinuation['effect'], resume, stateSignature: '', }; next.stateSignature = ordinaryDiscardSignature(g, next); stageTreacheryDiscard( g, `ordinary:${next.effect}`, [{ card, discardedBy: p.id, publicFace: true }], next, ); } type DistransDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'distransDiscard' } >; function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) { const owner = getPlayer(g, c.owner), recipient = getPlayer(g, c.recipient); return JSON.stringify({ owner: c.owner, recipient: c.recipient, transferred: c.transferred, card: c.card, parent: nullentropyParentSignature(g, c.resume, `distrans:${c.card}`), turn: g.turn, phase: g.phase, active: g.active, ownerHand: owner.hand.map(card => card.id), recipientHand: recipient.hand.map(card => card.id), }); } type SaphoMovementDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'saphoMovementDiscard' } >; function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, event: c.event, mode: c.mode, previousActive: c.previousActive, parent: nullentropyParentSignature(g, c.resume, c.event), turn: g.turn, phase: g.phase, advanced: g.advanced, active: g.active, order: g.order, remaining: g.movementRemaining, protectedLast: g.saphoMovementLast, guildTimingGranted: g.guildTimingGranted, guildTimingLocked: g.guildTimingLocked, hajr: g.hajr, karamaShipping: g.karamaShipping, choamMovement: g.choamMovement, players: g.players.map(p => ({ id: p.id, faction: p.faction, moved: p.moved, shipped: p.shipped, })), }); } type SaphoAuctionDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'saphoAuctionDiscard' } >; function saphoAuctionDiscardSignature(g: Game, c: SaphoAuctionDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, event: c.event, mode: c.mode, parent: nullentropyParentSignature(g, c.resume, c.event), turn: g.turn, phase: g.phase, active: g.active, order: g.order, lot: g.richeseAuction, bidding: g.richeseBidding, funding: g.richeseFunding, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, })), }); } type SaphoBattleOrderDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'saphoBattleOrderDiscard' } >; function saphoBattleOrderDiscardSignature(g: Game, c: SaphoBattleOrderDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, event: c.event, mode: c.mode, parent: nullentropyParentSignature(g, c.resume, c.event), turn: g.turn, phase: g.phase, active: g.active, order: g.order, battleOrder: g.battleOrder, useEvents: g.battleOrderUseEvents, afterBattle: g.lastBattleContext, battles: quoteCombatBoard(g).battles, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), forces: p.forces, })), }); } type SaphoAggressorDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'saphoAggressorDiscard' } >; function saphoAggressorDiscardSignature(g: Game, c: SaphoAggressorDiscardContinuation) { const battle = g.battle; return JSON.stringify({ player: c.player, card: c.card, battleEvent: c.battleEvent, saphoEvent: c.saphoEvent, parent: nullentropyParentSignature(g, c.resume, c.saphoEvent), turn: g.turn, phase: g.phase, advanced: g.advanced, active: g.active, order: g.order, battle: battle && { event: battle.event, attacker: battle.attacker, defender: battle.defender, chooser: battle.chooser, territory: battle.territory, preLeader: battle.preLeader, preparation: battle.preparation, saphoAggressor: battle.saphoAggressor, saphoAggressorEvents: battle.saphoAggressorEvents, plans: battle.plans, revealed: battle.revealed, voice: battle.voice, prescience: battle.prescience, fullPlan: battle.fullPlan, nexusInspection: battle.nexusInspection, truthPromises: battle.truthPromises, }, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally })), }); } type ResidualPoisonDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'residualPoisonDiscard' } >; function residualPoisonDiscardSignature(g: Game, c: ResidualPoisonDiscardContinuation) { return JSON.stringify({ player: c.player, target: c.target, victim: c.victim, battleEvent: c.battleEvent, card: c.card, parent: nullentropyParentSignature(g, c.resume, c.battleEvent), turn: g.turn, phase: g.phase, active: g.active, order: g.order, battle: g.battle, duke: g.dukeVidal, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), leaders: p.leaders, reserves: p.reserves, tanks: p.tanks, forces: p.forces, })), }); } type KaramaResponseDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' } >; function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, use: c.use, parent: nullentropyParentSignature(g, c.resume, c.kind === 'karamaCharityDiscard' ? `karama:charity:${c.card}` : `karama:${c.use.response.kind}:${c.card}`), turn: g.turn, phase: g.phase, advanced: g.advanced, charity: g.choamCharity, ...(c.kind === 'karamaInflationDiscard' ? { inflation: g.inflation, inflationUsed: g.inflationUsed, inflationAttemptTurn: g.inflationAttemptTurn, } : {}), ...(c.kind === 'karamaBgCharityDiscard' ? { claimed: getPlayer(g, c.use.response.owner).charityTurn === g.turn, } : {}), order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, })), }); } type KaramaAuctionDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'karamaPurchaseDiscard' | 'karamaPaymentDiscard' } >; function karamaAuctionDiscardSignature(g: Game, c: KaramaAuctionDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, parent: nullentropyParentSignature(g, c.resume, `karama:${c.kind === 'karamaPaymentDiscard' ? 'auctionPayment' : 'purchase'}:${c.card}`), turn: g.turn, phase: g.phase, active: g.active, auction: g.auction, richeseBidding: g.richeseBidding, richeseFunding: g.richeseFunding, richeseCache: g.richeseCache?.map(card => card.id), deck: g.deck.map(card => card.id), order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, })), }); } type ChoamSaleDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamSaleDiscard' } >; function choamSaleDiscardSignature(g: Game, c: ChoamSaleDiscardContinuation) { const owner = getPlayer(g, c.owner); return JSON.stringify({ owner: c.owner, card: c.card, witness: c.witness, price: c.price, spiceAfter: c.spiceAfter, turn: g.turn, phase: g.phase, active: g.active, order: g.order, market: g.choamMarket, hand: owner.hand.map(card => card.id), spice: owner.spice, }); } type ChoamKulonDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamKulonDiscard' } >; function choamKulonDiscardSignature(g: Game, c: ChoamKulonDiscardContinuation) { const owner = getPlayer(g, c.player); return JSON.stringify({ player: c.player, card: c.card, before: c.before, turn: g.turn, phase: g.phase, active: g.active, order: g.order, movement: g.choamMovement, remaining: g.movementRemaining, parent: nullentropyParentSignature(g, { response: null, decision: null, pendingKarama: null, phaseOpening: null }, `choam:kulon:${c.card}`), hand: owner.hand.map(card => card.id), moved: owner.moved, shipped: owner.shipped, spice: owner.spice, }); } type ChoamLaLaLaDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamLaLaLaDiscard' } >; function choamLaLaLaDiscardSignature(g: Game, c: ChoamLaLaLaDiscardContinuation) { const owner = getPlayer(g, c.player), target = getPlayer(g, c.target); return JSON.stringify({ player: c.player, card: c.card, target: c.target, turn: g.turn, phase: g.phase, active: g.active, order: g.order, revivalRules: g.revivalRules, pendingRevival: g.pendingRevival, parent: nullentropyParentSignature(g, { response: null, decision: null, pendingKarama: null, phaseOpening: null }, `choam:laLaLa:${c.card}`), ownerHand: owner.hand.map(card => card.id), targetRevived: target.revived, targetSpice: target.spice, }); } type ChoamBalisetDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamBalisetDiscard' } >; function choamBalisetDiscardSignature(g: Game, c: ChoamBalisetDiscardContinuation) { const owner = getPlayer(g, c.player); return JSON.stringify({ player: c.player, card: c.card, target: c.target, territory: c.territory, turn: g.turn, phase: g.phase, active: g.active, order: g.order, restrictions: g.choamBaliset, remaining: g.movementRemaining, parent: nullentropyParentSignature(g, { response: null, decision: null, pendingKarama: null, phaseOpening: null }, `choam:baliset:${c.card}`), ownerHand: owner.hand.map(card => card.id), ownerForces: owner.forces, ownerElites: owner.elites?.forces, ownerSpice: owner.spice, }); } type ChoamGamontDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamGamontDiscard' } >; function choamGamontDiscardSignature(g: Game, c: ChoamGamontDiscardContinuation) { const owner = getPlayer(g, c.player), target = getPlayer(g, c.target); return JSON.stringify({ player: c.player, card: c.card, target: c.target, location: c.location, turn: g.turn, phase: g.phase, active: g.active, order: g.order, ready: g.ready, storm: g.storm, parent: nullentropyParentSignature(g, { response: null, decision: null, pendingKarama: null, phaseOpening: null }, `choam:gamont:${c.card}`), ownerHand: owner.hand.map(card => card.id), targetForces: target.forces, targetReserves: target.reserves, targetElites: target.elites, targetNoField: target.noField, }); } type ThumperDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'thumperDiscard' } >; function thumperDiscardSignature(g: Game, c: ThumperDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, parent: nullentropyParentSignature(g, c.resume, `thumper:${c.card}`), turn: g.turn, phase: g.phase, advanced: g.advanced, expansions: g.expansions, active: g.active, order: g.order, ready: g.ready, storm: g.storm, nexus: g.nexus, sandtrout: g.sandtrout, summonedBeforeBlow: g.summonedBeforeBlow, wormRides: g.wormRides, wormPlacementCanceledTurn: g.wormPlacementCanceledTurn, spiceDeck: g.spiceDeck, spiceDiscard: g.spiceDiscard, spice: g.spice, aid: g.aid, allianceOffers: g.allianceOffers, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, forces: p.forces, reserves: p.reserves, tanks: p.tanks, advisors: p.advisors, noField: p.noField, elites: p.elites, })), }); } type AmalDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'amalDiscard' } >; function amalDiscardSignature(g: Game, c: AmalDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, before: c.before, parent: nullentropyParentSignature(g, c.resume, `amal:${c.card}`), turn: g.turn, phase: g.phase, advanced: g.advanced, expansions: g.expansions, active: g.active, order: g.order, ready: g.ready, storm: g.storm, deck: g.deck, richeseCache: g.richeseCache, auction: g.auction, spice: g.spice, spiceDeck: g.spiceDeck, players: g.players.map(p => ({ id: p.id, faction: p.faction, spice: p.spice, bribes: p.bribes, forces: p.forces, reserves: p.reserves, tanks: p.tanks, })), }); } type TruthDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'truthtranceDiscard' } >; function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { return JSON.stringify({ consumed: next.consumed, historyIndex: next.historyIndex, record: next.record, remaining: next.remaining, promise: next.promise, controls: nullentropyParentSignature( g, next.resume, `truth:${next.historyIndex}`, ), history: g.truthHistory, battle: g.battle, shipmentPromises: g.shipmentPromises, active: g.active, order: g.order, status: g.status, setupStage: g.setupStage, auction: g.auction, richeseAuction: g.richeseAuction, currentAuctionSale: g.currentAuctionSale, ornithopter: g.ornithopter, spiceWindow: g.spiceWindow, spiceResolution: g.spiceResolution, wormRides: g.wormRides, summonedWorm: g.summonedWorm, }); } /** Truth validation, promise binding and the public answer have already happened. */ function finishTruthtranceAnswer(g: Game) { const window = g.truthtrance!; const consumed = window.queue[0]; const historyIndex = g.truthHistory!.length - 1; const record = g.truthHistory![historyIndex]; const card = discard(g, getPlayer(g, consumed.player), consumed.card); let promise: TruthDiscardContinuation['promise'] = null; if (record.question.kind === 'shipment') promise = { kind: 'shipment', index: g.shipmentPromises!.length - 1, value: g.shipmentPromises!.at(-1)!, }; else if ( record.question.kind === 'battlePlan' && g.battle && !g.battle.revealed && !g.battle.plans[record.question.target] ) promise = { kind: 'battle', index: g.battle.truthPromises!.length - 1, value: g.battle.truthPromises!.at(-1)!, }; const resume = { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening, }; const remaining: TruthWindow | null = window.queue.length > 1 ? { stage: 'ask', queue: window.queue.slice(1), passed: window.passed, question: null, } : null; g.truthtrance = null; g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; const continuation: TruthDiscardContinuation = { kind: 'truthtranceDiscard', consumed, historyIndex, record, remaining, resume, promise, parentSignature: '', }; continuation.parentSignature = truthtranceDiscardSignature(g, continuation); stageTreacheryDiscard( g, 'truthtrance', [{ card, discardedBy: consumed.player, publicFace: true }], continuation, ); } /** Bind the committed movement and its remaining arrival/turn obligations. * This receipt is never restored over live state and excludes seat AI controls. */ function ornithopterDiscardSignature( g: Game, next: Extract< NonNullable['continuation'], { kind: 'ornithopterDiscard' } >, ) { return JSON.stringify({ source: next.source, flight: next.flight, movement: next.movement, controls: nullentropyParentSignature(g, next.resume, next.flight.event), active: g.active, order: g.order, movementRemaining: g.movementRemaining, guildTimingGranted: g.guildTimingGranted, guildTimingLocked: g.guildTimingLocked, saphoMovementLast: g.saphoMovementLast, karamaShipping: g.karamaShipping, ambassadors: g.ecazAmbassadors, terror: g.moritaniTerror, players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, allySinceTurn: p.allySinceTurn, forces: p.forces, elites: p.elites, reserves: p.reserves, tanks: p.tanks, advisors: p.advisors, noField: p.noField, noFieldEvent: p.noFieldEvent, moved: p.moved, shipped: p.shipped, })), }); } function suspendedControlsIntegrity( g: Game, resume: NonNullable['resume'], ) { const seated = (id: string) => g.players.some((p) => p.id === id); const passes = (ids: string[]) => Array.isArray(ids) && new Set(ids).size === ids.length && ids.every(seated); const response = (r: ResponseWindow | null | undefined) => !r || (typeof r.kind === 'string' && seated(r.owner) && passes(r.passed)); requireRule( resume && typeof resume === 'object' && !Array.isArray(resume) && Object.keys(resume).every((key) => ['response', 'decision', 'pendingKarama', 'phaseOpening'].includes(key), ) && response(resume.response) && (!resume.decision || (resume.decision.kind !== 'nullentropy' && typeof resume.decision.kind === 'string' && seated(resume.decision.player))) && (!resume.phaseOpening || (typeof resume.phaseOpening.initialize === 'boolean' && passes(resume.phaseOpening.passed))) && (!resume.pendingKarama || (seated(resume.pendingKarama.owner) && resume.pendingKarama.use && ['cancel', 'shipment', 'purchase', 'auctionPayment'].includes( resume.pendingKarama.use.kind, ) && (resume.pendingKarama.use.kind !== 'cancel' || response(resume.pendingKarama.use.response)))) && (resume.response?.kind !== 'worthlessKarama' || !!resume.pendingKarama), 'The completed discard does not match its suspended controls and obligations.', ); // Validate restored controls without advancing or replaying their effects. const context = structuredClone(g); context.pendingTreacheryDiscard = null; Object.assign(context, structuredClone(resume)); karamaConversionIntegrity(context); shipmentPromiseIntegrity(context); saphoMovementIntegrity(context); ornithopterIntegrity(context); lateDefenseIntegrity(context); battleSpecialWeaponIntegrity(context); strongholdIntegrity(context); auditorIntegrity(context); } function treacheryDiscardIntegrity(g: Game) { requireRule(g.semutaPreview === undefined || g.semutaPreview === true && g.expansions.includes('choam') && !!byFaction(g, 'richese') && !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.discoveries && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards, 'This Semuta preview no longer matches its original Richese profile.'); const pending = g.pendingTreacheryDiscard; const sequence = g.treacheryDiscardSequence ?? 0; const resolved = g.resolvedTreacheryDiscardSequence ?? 0; requireRule( Number.isSafeInteger(sequence) && sequence >= 0 && Number.isSafeInteger(resolved) && resolved >= 0 && sequence === resolved + (pending ? 1 : 0), 'The saved discard sequence is inconsistent or already resolved.', ); if (!pending) return; const { batch, continuation } = pending; const box = continuation?.kind === 'nullentropyDiscard'; const truth = continuation?.kind === 'truthtranceDiscard'; const ambassadorTerror = continuation?.kind === 'terrorDiscard' && continuation.entry.cause === 'ambassador'; requireRule( (g.status === 'playing' || (truth && g.status === 'setup')) && pending.sequence === sequence && batch && batch.event === `discard:${g.turn}:${g.phase}:${sequence}` && batch.turn === g.turn && batch.phase === g.phase && Array.isArray(batch.entries) && batch.entries.length > 0 && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && (!g.pendingRicheseGift || box || truth) && !g.pendingKarama && !g.pendingNullentropy && (!g.pendingAmbassador || box || truth || ambassadorTerror) && (!g.pendingIxAlly || box || truth), 'The saved discard does not match its current continuation.', ); const ids = new Set(); // Auction prefixes and Ix knowledge are receipts, not additional custody. const outside = [ ...g.players.flatMap((p) => p.hand), ...g.deck, ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), ...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []), ...(g.ornithopter ? [g.ornithopter.card] : []), ...(g.auction?.cards.slice( g.auction.index + (g.currentAuctionSale ? 1 : 0), ) ?? []), ]; for (const entry of Array.from(batch.entries)) { const card = entry?.card; const actual = card && g.discard.filter((c) => c.id === card.id); requireRule( entry && card && typeof card.id === 'string' && card.id.length > 0 && !ids.has(card.id) && typeof entry.publicFace === 'boolean' && g.players.some((p) => p.id === entry.discardedBy) && actual?.length === 1 && actual[0].name === card.name && actual[0].kind === card.kind && actual[0].effect === card.effect && !outside.some((c) => c.id === card.id), 'The fresh discard receipt has conflicting physical custody.', ); ids.add(card.id); } const reaction = pending.reaction; requireRule(reaction !== undefined || !semutaOfferSupported(g, continuation, batch.entries), 'The saved fresh discard lost its neutral Semuta reaction.'); if (reaction !== undefined) { requireRule(semutaOfferSupported(g, continuation, batch.entries), 'This saved Semuta opportunity has no supported fresh discard.'); const physical = physicalTreacheryCards(g).filter(card => card.id === SEMUTA_DRUG_ID); requireRule(physical.length === 1 && richeseCardDefinition(physical[0])?.card.effect === 'semutaDrug', 'The saved Semuta reaction lost unique physical card custody.'); if (reaction.stage === 'offer') { requireRule(Object.keys(reaction).sort().join(',') === 'passed,stage' && Array.isArray(reaction.passed) && new Set(reaction.passed).size === reaction.passed.length && reaction.passed.length < g.players.length && reaction.passed.every(id => g.players.some(p => p.id === id)), 'The neutral Semuta passes lost their original participants.'); } else if (reaction.stage === 'select') { const owner = g.players.find(p => p.id === reaction.player); requireRule(Object.keys(reaction).sort().join(',') === 'candidates,player,stage' && owner && Array.isArray(reaction.candidates) && reaction.candidates.length >= 1 && JSON.stringify(reaction.candidates) === JSON.stringify(semutaCandidates(g, owner).map(card => card.id)), 'The committed Semuta claim lost its private fresh candidates.'); } else throw new RuleError('Unknown saved Semuta reaction stage.'); } if (continuation?.kind === 'nullentropyDiscard') { const c = continuation; const owner = g.players.find((p) => p.id === c.player); const selected = c.selected && owner?.hand.filter((card) => card.id === c.selected.id); requireRule( owner && typeof c.searchEvent === 'string' && c.searchEvent.length > 0 && batch.cause === 'nullentropyBox' && batch.entries.length === 1 && batch.entries[0].discardedBy === c.player && batch.entries[0].publicFace && batch.entries[0].card.id === c.box && richeseCardDefinition(batch.entries[0].card)?.card.effect === 'nullentropyBox' && g.discard.at(-1)?.id === c.box && Array.isArray(c.finalDiscardIds) && new Set(c.finalDiscardIds).size === c.finalDiscardIds.length && JSON.stringify(c.finalDiscardIds) === JSON.stringify(g.discard.map((card) => card.id)) && c.finalDiscardSignature === JSON.stringify(g.discard) && selected?.length === 1 && c.selected.effect !== 'nullentropyBox' && c.selected.name !== 'Nullentropy Box' && (['id', 'name', 'kind', 'effect'] as const).every( (key) => selected[0][key] === c.selected[key], ) && outside.filter((card) => card.id === c.selected.id).length === 1 && !g.discard.some((card) => card.id === c.selected.id) && g.discard.every( (card) => !outside.some((other) => other.id === card.id), ), 'The completed Box search has inconsistent selected-card or shuffled-pile custody.', ); requireRule( c.parentSignature === nullentropyParentSignature(g, c.resume, c.searchEvent), 'The completed Box search does not match its suspended controls and obligations.', ); suspendedControlsIntegrity(g, c.resume); } else if (continuation?.kind === 'ordinaryCardDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find((p) => p.id === c.player); requireRule( owner && ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes( c.effect, ) && batch.cause === `ordinary:${c.effect}` && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.kind === 'special' && entry.card.effect === c.effect && c.stateSignature === ordinaryDiscardSignature(g, c), 'The consumed ordinary card no longer matches its completed effect.', ); suspendedControlsIntegrity(g, c.resume); requireRule( !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening, 'The ordinary card has an unexpected suspended decision.', ); const income = c.resume.response, tleilaxu = byFaction(g, 'tleilaxu'); requireRule( c.effect === 'ghola' && tleilaxu ? income?.kind === 'revivalIncome' && income.owner === tleilaxu.id && income.recipient === c.player && income.amount === 1 && income.passed.length === 0 : !income, 'The consumed card no longer matches its pending revival income.', ); requireRule( c.effect === 'hajr' ? g.phase === 5 && g.active === c.player && g.hajr.filter((id) => id === c.player).length === 1 : c.effect === 'weather' ? g.phase === 0 && Number.isSafeInteger(g.stormPending) && g.stormPending! >= 0 && g.stormPending! <= 10 && g.ready.length === 0 : c.effect === 'atomics' ? g.phase === 0 && g.shieldWallDestroyed && g.ready.length === 0 && g.players.every( (p) => !Object.entries(p.forces).some( ([key, n]) => key.startsWith('shield_wall:') && n > 0, ), ) : c.effect === 'harvester' ? g.phase === 1 && !!g.spiceWindow?.harvested && Number.isSafeInteger(g.spiceWindow.harvesters) && g.spiceWindow.harvesters! > 0 && g.ready.length === 0 : true, 'The completed ordinary-card outcome has changed.', ); } else if (continuation?.kind === 'distransDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.owner); const recipient = g.players.find(p => p.id === c.recipient); requireRule( owner && recipient && owner !== recipient && batch.cause === 'distrans' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.owner && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'distrans' && recipient.hand.filter(card => card.id === c.transferred).length === 1 && !owner.hand.some(card => card.id === c.transferred || card.id === c.card) && outside.filter(card => card.id === c.transferred).length === 1 && !g.discard.some(card => card.id === c.transferred) && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === distransDiscardSignature(g, c), 'The completed private Distrans transfer no longer matches its fresh discard.', ); } else if (continuation?.kind === 'saphoMovementDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( g.phase === 5 && g.active === c.previousActive && c.event === `movement:${g.turn}` && g.players.some(p => p.id === c.player) && Array.isArray(g.movementRemaining) && g.movementRemaining.length > 1 && new Set(g.movementRemaining).size === g.movementRemaining.length && g.movementRemaining.every(id => g.order.includes(id)) && g.movementRemaining.includes(c.previousActive) && (c.mode === 'first' ? g.movementRemaining[0] === c.player && c.previousActive !== c.player && !g.saphoMovementLast : c.mode === 'last' && g.movementRemaining.at(-1) === c.player && g.saphoMovementLast?.event === c.event && g.saphoMovementLast.turn === g.turn && g.saphoMovementLast.player === c.player) && batch.cause === 'sapho:movement' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === saphoMovementDiscardSignature(g, c), 'The used Juice of Sapho no longer matches its committed movement order.', ); } else if (continuation?.kind === 'saphoAuctionDiscard') { const c = continuation, entry = batch.entries[0], lot = g.richeseAuction; const remaining = lot?.order.filter(id => !lot.acted.includes(id)); requireRule( g.phase === 3 && lot?.method === 'onceAround' && lot.event === c.event && !lot.outcome && lot.eligible.includes(c.player) && !lot.acted.includes(c.player) && (c.mode === 'first' ? remaining?.[0] === c.player && lot.active === c.player : c.mode === 'last' && remaining?.at(-1) === c.player) && g.active === lot.active && batch.cause === 'sapho:auction' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === saphoAuctionDiscardSignature(g, c), 'The used Juice of Sapho no longer matches its committed Once Around order.', ); } else if (continuation?.kind === 'saphoBattleOrderDiscard') { const c = continuation, entry = batch.entries[0], order = g.battleOrder; const use = order?.uses.at(-1); requireRule( g.phase === 6 && !g.battle && order && use && use.event === c.event && use.player === c.player && use.mode === c.mode && g.battleOrderUseEvents?.at(-1) === c.event && g.active === battleOrderQuote(g).current && batch.cause === 'sapho:battleOrder' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === saphoBattleOrderDiscardSignature(g, c), 'The used Juice of Sapho no longer matches its committed battle chooser order.', ); } else if (continuation?.kind === 'saphoAggressorDiscard') { const c = continuation, entry = batch.entries[0], battle = g.battle; requireRule( g.phase === 6 && battle && battle.event === c.battleEvent && battle.preLeader?.event === c.battleEvent && !battle.preLeader.closed && !battle.preLeader.ready.includes(c.player) && [battle.attacker, battle.defender].includes(c.player) && !battle.revealed && Object.keys(battle.plans).length === 0 && battle.saphoAggressor?.uses.at(-1)?.event === c.saphoEvent && battle.saphoAggressor.uses.at(-1)?.player === c.player && battle.saphoAggressorEvents?.includes(c.saphoEvent) && batch.cause === 'sapho:aggressor' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === saphoAggressorDiscardSignature(g, c), 'The used Juice of Sapho no longer matches its pre-plan aggressor choice.', ); saphoAggressorIntegrity(g); } else if (continuation?.kind === 'residualPoisonDiscard') { const c = continuation, entry = batch.entries[0], battle = g.battle; const victim = g.players.some(p => p.id === c.target) ? controlledLeaders(g, getPlayer(g, c.target)).find(l => l.id === c.victim) : null; requireRule( g.phase === 6 && battle && battle.event === c.battleEvent && !battle.revealed && !Object.keys(battle.plans).length && [battle.attacker, battle.defender].includes(c.player) && c.target === (battle.attacker === c.player ? battle.defender : battle.attacker) && battle.preLeader?.event === c.battleEvent && !battle.preLeader.closed && battle.preLeader.ready.length === 0 && victim?.dead && !victim.capturedBy && batch.cause === 'residualPoison' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'residualPoison' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === residualPoisonDiscardSignature(g, c), 'The used Residual Poison no longer matches its committed leader death.', ); } else if (continuation?.kind === 'karamaCharityDiscard' || continuation?.kind === 'karamaInflationDiscard' || continuation?.kind === 'karamaBgCharityDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( !g.response && ((c.kind === 'karamaCharityDiscard' && c.use.response.kind === 'choamCharity' && g.phase === 2 && g.choamCharity?.turn !== g.turn) || (c.kind === 'karamaInflationDiscard' && c.use.response.kind === 'choamInflation' && g.phase === 8 && g.inflationAttemptTurn === g.turn && !g.inflation && !g.inflationUsed) || (c.kind === 'karamaBgCharityDiscard' && c.use.response.kind === 'bgCharity' && g.phase === 2 && g.players.some(p => p.id === c.use.response.owner && p.faction === 'beneGesserit' && p.charityTurn === g.turn))) && c.use.response.owner !== c.player && c.resume.response?.kind === c.use.response.kind && JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && batch.cause === `karama:${c.use.response.kind}` && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.effect === 'karama' && g.players.some(p => p.id === c.player) && c.stateSignature === karamaResponseDiscardSignature(g, c), 'The spent Karama no longer matches its suspended charity or Inflation cancellation.', ); } else if (continuation?.kind === 'karamaPurchaseDiscard' || continuation?.kind === 'karamaPaymentDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( g.phase === 3 && !g.response && !g.decision && !g.pendingKarama && !g.phaseOpening && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && batch.cause === (c.kind === 'karamaPaymentDiscard' ? 'karama:auctionPayment' : 'karama:purchase') && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.effect === 'karama' && g.players.some(p => p.id === c.player) && c.stateSignature === karamaAuctionDiscardSignature(g, c), 'The spent Karama no longer matches its reserved auction settlement.', ); normalKaramaAuction(g, getPlayer(g, c.player), c.kind === 'karamaPaymentDiscard'); } else if (continuation?.kind === 'choamSaleDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.owner); requireRule( g.choamMarket?.owner === c.owner && !g.choamMarket.sale && !g.choamMarket.trade && !g.response && !g.decision && !g.pendingKarama && !g.phaseOpening && owner?.faction === 'choam' && batch.cause === 'choamSale' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.owner && entry.card.id === c.card && (c.witness === null ? c.price === 2 && entry.card.kind === 'worthless' : c.price === 3 && owner.hand.some(card => card.id === c.witness && card.name === entry.card.name)) && owner.spice === c.spiceAfter && c.stateSignature === choamSaleDiscardSignature(g, c), 'The sold CHOAM card no longer matches its saved income and market.', ); } else if (continuation?.kind === 'choamKulonDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.player); requireRule( g.phase === 5 && g.active === c.player && owner?.faction === 'choam' && owner.moved < movesAllowed(g, owner) && !g.pendingChoamWorthless && !g.response && !g.decision && !g.phaseOpening && !g.ornithopter && !g.pendingChoamMove && batch.cause === 'choam:kulon' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.kind === 'worthless' && entry.card.name === 'Kulon' && Number.isSafeInteger(c.before) && c.before >= 0 && c.before === (g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) && c.stateSignature === choamKulonDiscardSignature(g, c), 'The used Kulon no longer matches its pending movement bonus.', ); } else if (continuation?.kind === 'choamLaLaLaDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.player); requireRule( g.phase === 4 && owner?.faction === 'choam' && g.players.some(p => p.id === c.target) && !g.revivalRules?.freeBlocked?.includes(c.target) && !g.pendingChoamWorthless && !g.pendingRevival && !g.response && !g.decision && !g.phaseOpening && batch.cause === 'choam:laLaLa' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.kind === 'worthless' && entry.card.name === 'La La La' && c.stateSignature === choamLaLaLaDiscardSignature(g, c), 'The used La La La no longer matches its pending revival block.', ); } else if (continuation?.kind === 'choamBalisetDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.player); requireRule( g.phase === 5 && owner?.faction === 'choam' && c.target !== c.player && g.players.some(p => p.id === c.target) && at(owner, c.territory) > 0 && !g.pendingChoamWorthless && !g.pendingChoamMove && !g.response && !g.decision && !g.phaseOpening && !g.ornithopter && batch.cause === 'choam:baliset' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.kind === 'worthless' && entry.card.name === 'Baliset' && c.stateSignature === choamBalisetDiscardSignature(g, c), 'The used Baliset no longer matches its pending territory restriction.', ); } else if (continuation?.kind === 'choamGamontDiscard') { const c = continuation, entry = batch.entries[0]; const owner = g.players.find(p => p.id === c.player); requireRule( g.phase === 8 && owner?.faction === 'choam' && c.target !== c.player && gamontAvailable(g, c.target, c.location, 0) && !g.pendingChoamWorthless && !g.response && !g.decision && !g.phaseOpening && !g.choamMarket && batch.cause === 'choam:gamont' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.kind === 'worthless' && entry.card.name === 'Trip to Gamont' && c.stateSignature === choamGamontDiscardSignature(g, c), 'The used Trip to Gamont no longer matches its pending ordinary force return.', ); } else if (continuation?.kind === 'thumperDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( g.players.some(p => p.id === c.player) && g.phase === 1 && !g.spiceSequence && !g.spiceResolution && !g.spiceWindow && !g.nexus && batch.cause === 'thumper' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.name === 'Thumper' && entry.card.kind === 'special' && entry.card.effect === 'thumper' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === thumperDiscardSignature(g, c), 'The used Thumper no longer matches its undrawn Spice Blow.', ); } else if (continuation?.kind === 'amalDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( g.expansions.includes('ix') && g.players.some(p => p.id === c.player) && batch.cause === 'amal' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && entry.card.name === 'Amal' && entry.card.kind === 'special' && entry.card.effect === 'amal' && c.resume && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && c.resume.phaseOpening && Array.isArray(c.resume.phaseOpening.passed) && c.resume.phaseOpening.passed.length === 0 && typeof c.resume.phaseOpening.initialize === 'boolean' && Array.isArray(c.before) && c.before.length === g.players.length && c.before.every((row, index) => row.player === g.players[index].id && Number.isSafeInteger(row.spice) && row.spice >= 0 && g.players[index].spice === Math.floor(row.spice / 2)) && c.stateSignature === amalDiscardSignature(g, c), 'The used Amal no longer matches its halved spice and saved phase opening.', ); suspendedControlsIntegrity(g, c.resume); } else if (continuation?.kind === 'truthtranceDiscard') { const c = continuation, entry = batch.entries[0], record = c.record; requireRule( c.consumed && typeof c.consumed.player === 'string' && (c.consumed.source === undefined || c.consumed.source === 'shrine') && batch.cause === 'truthtrance' && batch.entries.length === 1 && entry.publicFace && entry.card.effect === (c.consumed.source === 'shrine' ? 'karama' : 'truthtrance') && entry.card.id === c.consumed.card && entry.discardedBy === c.consumed.player && record && record.asker === c.consumed.player && record.turn === g.turn && record.phase === g.phase && ['yes', 'no'].includes(record.answer) && Array.isArray(g.truthHistory) && Number.isSafeInteger(c.historyIndex) && c.historyIndex >= 0 && c.historyIndex === g.truthHistory.length - 1 && JSON.stringify(g.truthHistory[c.historyIndex]) === JSON.stringify(record), 'The consumed Truthtrance does not match its completed answer history.', ); const remaining = c.remaining; requireRule( remaining === null || (remaining && remaining.stage === 'ask' && remaining.question === null && Array.isArray(remaining.queue) && remaining.queue.length > 0 && new Set(remaining.queue.map((e) => e?.card)).size === remaining.queue.length && remaining.queue.every( (e) => e && e.card !== c.consumed.card && typeof e.card === 'string' && e.card.length > 0 && typeof e.player === 'string' && queuedTruthCardMatches( g.players.find((p) => p.id === e.player)?.hand ?? [], e, ) && outside.filter((card) => card.id === e.card).length === 1 && !g.discard.some((card) => card.id === e.card), ) && Array.isArray(remaining.passed) && new Set(remaining.passed).size === remaining.passed.length && remaining.passed.every((id) => g.players.some((p) => p.id === id))), 'The remaining Truthtrance queue no longer matches its held cards and priority.', ); requireRule( c.parentSignature === truthtranceDiscardSignature(g, c), 'The completed Truthtrance no longer matches its recorded promises and parent.', ); suspendedControlsIntegrity(g, c.resume); const context = structuredClone(g); context.pendingTreacheryDiscard = null; Object.assign(context, structuredClone(c.resume)); try { validateTruthQuestionReceipt(context, c.consumed.player, record.question); } catch (error) { if (error instanceof TruthError || error instanceof PlanClaimError) throw new RuleError(error.message); throw error; } const q = record.question, promise = c.promise; const expectedShipment = q.kind === 'shipment'; const expectedBattle = q.kind === 'battlePlan' && g.battle && !g.battle.revealed && !g.battle.plans[q.target]; if (expectedShipment || expectedBattle) { const actual = expectedShipment ? g.shipmentPromises : g.battle?.truthPromises; requireRule( promise && promise.value && typeof promise.value === 'object' && promise.kind === (expectedShipment ? 'shipment' : 'battle') && Array.isArray(actual) && Number.isSafeInteger(promise.index) && promise.index >= 0 && promise.index === (actual?.length ?? 0) - 1 && JSON.stringify(promise.value) === JSON.stringify(actual?.[promise.index]) && promise.value.player === q.target && promise.value.asker === record.asker && promise.value.answer === (record.answer === 'yes') && !promise.value.released, 'The completed Truthtrance is missing its newly bound promise.', ); if (promise.kind === 'shipment') requireRule( q.kind === 'shipment' && promise.value.turn === g.turn && !promise.value.fulfilled && promise.value.territory === q.territory && promise.value.minimum === q.minimum, 'The completed Truthtrance shipment promise differs from its answer.', ); else requireRule( q.kind === 'battlePlan' && JSON.stringify(promise.value.claim) === JSON.stringify(q.claim), 'The completed Truthtrance battle promise differs from its answer.', ); } else requireRule( promise === null, 'This Truthtrance answer did not create a new promise.', ); } else if (continuation?.kind === 'ornithopterDiscard') { const c = continuation, flight = c.flight; const owner = flight && g.players.find((p) => p.id === flight.player); const total = flight?.mode === 'twoGroups' ? 2 : 1; requireRule( owner && g.phase === 5 && g.active === owner.id && !g.ornithopter && !g.pendingChoamMove && !g.pendingIxMove && !g.pendingFremenMove && !g.pendingTerrorEntry && !g.pendingExchange && !g.battle && flight.turn === g.turn && typeof flight.event === 'string' && flight.event.length > 0 && ['range3', 'twoGroups'].includes(flight.mode) && Number.isSafeInteger(flight.startingMove) && flight.startingMove >= 0 && Number.isSafeInteger(flight.completed) && flight.completed >= 0 && owner.moved === flight.startingMove + flight.completed && ['move', 'end'].includes(c.source) && (c.source === 'move' ? flight.completed === total : flight.completed < total) && batch.cause === `ornithopter:${c.source}` && batch.entries.length === 1 && batch.entries[0].publicFace && batch.entries[0].discardedBy === owner.id && flight.card && richeseCardDefinition(flight.card)?.card.effect === 'ornithopter' && (['id', 'name', 'kind', 'effect'] as const).every( (key) => flight.card[key] === batch.entries[0].card[key], ), 'The retired Ornithopter does not match its completed flight and discard.', ); if (c.source === 'move') { const m = c.movement; requireRule( m && m.player === owner.id && owner.shipped && gameTerritories(g).some((t) => t.id === m.origin) && validGameLocation(g, m.to, m.sector) && Number.isSafeInteger(m.total) && m.total > 0 && typeof m.noField === 'boolean' && Number.isSafeInteger(m.elite) && m.elite >= 0 && m.elite <= m.total - (m.noField ? 1 : 0) && (owner.forces[location(m.to, m.sector)] ?? 0) >= m.total - (m.noField ? 1 : 0) && (owner.elites?.forces[location(m.to, m.sector)] ?? 0) >= m.elite && (!m.noField || (owner.noField?.deployed?.location.territory === m.to && owner.noField.deployed.location.sector === m.sector)), 'The retired Ornithopter arrival differs from its committed movement.', ); } else requireRule( c.movement === null, 'An ended Ornithopter has no arrival to replay.', ); requireRule( c.stateSignature === ornithopterDiscardSignature(g, c), 'The retired Ornithopter no longer matches its saved movement obligations.', ); suspendedControlsIntegrity(g, c.resume); } else if (continuation?.kind === 'ambassador') { const entry = continuation.entry; const owner = g.players.find((p) => p.id === entry?.owner); const beneficiary = g.players.find((p) => p.id === entry?.beneficiary); requireRule( entry && entry.turn === g.turn && entry.phase === g.phase && typeof entry.event === 'string' && entry.event.length > 0 && entry.stage === 'cards' && ['choam', 'ixians'].includes(entry.effect!) && ['none', 'wormRide'].includes(entry.resume) && validAmbassadorResume(g, entry) && TERRITORIES.some( (t) => t.id === entry.territory && t.type === 'stronghold' && t.sectors.includes(entry.sector), ) && owner?.faction === 'ecaz' && beneficiary && (beneficiary.id === owner.id || (beneficiary.ally === owner.id && owner.ally === beneficiary.id)) && g.players.some((p) => p.id === entry.entrant) && g.ecazAmbassadors?.tokens.some( (token) => token.id === entry.token && ((token.effect === entry.effect && token.zone === 'used') || (token.effect === 'beneGesserit' && token.zone === 'removed' && Array.isArray(entry.copyChoices) && entry.copyChoices.includes(entry.effect!))), ) && batch.cause === `ambassador:${entry.effect}` && batch.entries.every( (c) => c.discardedBy === entry.beneficiary && !c.publicFace, ) && (entry.effect !== 'ixians' || batch.entries.length === 1), 'The saved Ambassador discard continuation is inconsistent.', ); } else if (continuation?.kind === 'terrorDiscard') { const { entry, owner: ownerId, source, discardedHandSize } = continuation; const owner = g.players.find((p) => p.id === ownerId); const entrant = g.players.find((p) => p.id === entry?.entrant); const tokens = g.moritaniTerror?.tokens.filter( (t) => t.id === entry?.token, ); if (entry?.cause === 'ambassador') ambassadorRelocationIntegrity(g); const sabotage = source === 'sabotage'; const discarder = sabotage ? entrant : owner; requireRule( !g.pendingTerrorEntry && !g.pendingExchange && !g.summonedWorm && entry && entry.turn === g.turn && entry.phase === g.phase && ['sabotage', 'robberyOverflow'].includes(source) && owner?.faction === 'moritani' && entrant && entrant.id !== owner.id && terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory,entry.sector) && [ 'shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'ambassador', ].includes(entry.cause) && ['none', 'wormRide', 'ambassador'].includes(entry.resume) && (entry.cause === 'ambassador' ? ambassadorTerrorEntryMatches(g, entry) : entry.cause === 'wormRide' ? (entry.resume === 'wormRide' || overlapOwnsWormTerror(g, entry)) && g.phase === 1 && entrant.faction === 'fremen' : entry.resume === 'none') && Number.isSafeInteger(entry.amount) && entry.amount >= 0 && Number.isSafeInteger(entry.elite) && entry.elite >= 0 && entry.elite <= entry.amount && tokens?.length === 1 && tokens[0].kind === (sabotage ? 'sabotage' : 'robbery') && tokens[0].status === 'removed' && tokens[0].location === null && entry.stage === (sabotage ? 'offer' : 'discard') && batch.cause === `terror:${source}` && batch.entries.length === 1 && !batch.entries[0].publicFace && discarder && batch.entries[0].discardedBy === discarder?.id && Number.isSafeInteger(discardedHandSize) && discardedHandSize > 0 && discardedHandSize === discarder.hand.length + 1 && (sabotage || discardedHandSize > handLimit(owner)), 'The saved Terror discard does not match its consumed entry and hand.', ); } else if (continuation?.kind === 'terrorAtomics') { const { entry, aftermath, handBefore } = continuation; atomicsIntegrity(g); const owner = byFaction(g, 'moritani'); const token = g.moritaniTerror?.tokens.find(row => row.id === entry?.token); requireRule(!g.pendingTerrorEntry && !g.pendingExchange && !g.summonedWorm && entry && entry.turn === g.turn && entry.phase === g.phase && entry.stage === 'offer' && entry.resume === (entry.cause === 'wormRide' ? 'wormRide' : 'none') && (entry.cause !== 'wormRide' || (g.phase === 1 && g.players.some(player => player.id === entry.entrant && player.faction === 'fremen'))) && ['shipment', 'movement', 'advisor', 'wormRide'].includes(entry.cause) && entry.entrant !== owner?.id && entry.territory === aftermath.territory && validGameLocation(g, entry.territory, entry.sector) && token?.kind === 'atomics' && token.status === 'removed' && token.location === null && batch.cause === 'terror:atomics' && batch.entries.length > 0 && batch.entries.every(row => !row.publicFace && (row.discardedBy === owner?.id || row.discardedBy === aftermath.allyAtActivation)) && Array.isArray(handBefore) && handBefore.length === (aftermath.allyAtActivation ? 2 : 1) && handBefore.every((row, index) => { const id = index === 0 ? owner?.id : aftermath.allyAtActivation; const player = g.players.find(seat => seat.id === id); const discarded = batch.entries.reduce((count, card) => count + Number(card.discardedBy === row.player), 0); return player && row.player === id && Number.isSafeInteger(row.count) && row.count >= 0 && row.count === player.hand.length + discarded && discarded === Math.max(0, row.count - handLimit(player)); }), 'The saved Atomics random discards lost their revealed entry or hand custody.'); } else if (continuation?.kind === 'ixAllyCard') { const sale = g.currentAuctionSale; const buyer = g.players.find((p) => p.id === continuation.player); const ixians = byFaction(g, 'ixians'); const lot = g.richeseAuction; requireRule( g.phase === 3 && sale && continuation.sale && Number.isSafeInteger(sale.amount) && sale.amount >= 0 && typeof sale.free === 'boolean' && ['normal', 'blackMarket'].includes(sale.origin) && buyer && ixians && buyer.ally === ixians.id && ixians.ally === buyer.id && (['winner', 'amount', 'free', 'origin', 'seller'] as const).every( (key) => sale[key] === continuation.sale[key], ) && continuation.player === sale.winner && continuation.free === sale.free && continuation.auctionIndex === (g.auction?.index ?? null) && continuation.auctionEvent === (g.richeseAuction?.event ?? null) && batch.cause === 'ixAllyCard' && batch.entries.length === 1 && batch.entries[0].card.id === continuation.card && batch.entries[0].discardedBy === continuation.player && batch.entries[0].publicFace === false && (sale.origin === 'normal' ? sale.seller === null && g.auction?.bidder === buyer.id && g.auction.bid === sale.amount && g.auction.cards[g.auction.index]?.id === continuation.card : !sale.free && lot?.source === 'blackMarket' && lot.owner === sale.seller && g.players.some((p) => p.id === sale.seller && p.id !== buyer.id) && lot.outcome?.kind === 'sold' && lot.outcome.winner === buyer.id && lot.outcome.amount === sale.amount && lot.cardId === continuation.card), 'The saved Ixian replacement no longer matches its paid auction.', ); } else if ( continuation?.kind === 'battleResolved' || continuation?.kind === 'battleCleanup' ) { const c = continuation; const context = g.lastBattleContext; requireRule( g.phase === 6 && !g.battle && context && context.turn === g.turn && typeof context.event === 'string' && context.event.length > 0 && c.event === context.event && c.territory === context.territory && JSON.stringify(c.combatants) === JSON.stringify(context.combatants) && Array.isArray(c.combatants) && c.combatants.length === 2 && new Set(c.combatants).size === 2 && JSON.stringify(c.combatants) === JSON.stringify(g.lastBattle) && c.combatants.every((id) => g.players.some((p) => p.id === id)) && combatLocations(g).some((t) => t.id === c.territory) && batch.entries.every( (e) => e.publicFace && c.combatants.includes(e.discardedBy), ), 'The saved battle discard does not match its resolved combatants.', ); if (c.kind === 'battleResolved' && context.mandatoryDiscard) { const receipt = context.mandatoryDiscard; requireRule( Array.isArray(receipt.entries) && receipt.entries.length === batch.entries.length && receipt.entries.every(entry => typeof entry.card === 'string' && typeof entry.owner === 'string') && receipt.signature === battleMandatoryDiscardSignature(context) && JSON.stringify(receipt.entries) === JSON.stringify(batch.entries.map(entry => ({ card: entry.card.id, owner: entry.discardedBy, }))), 'The fresh mandatory battle cards no longer match their former owners.', ); } // Projection must validate the private Auditor owner as strictly as actions. auditorIntegrity(g); const loser = c.combatants.find((id) => id !== context.winner); requireRule( (!g.pendingAuditor || (g.pendingAuditor.stage === 'offer' && g.pendingAuditor.event === context.event && g.pendingAuditor.territory === c.territory)) && (!g.pendingCapture || (g.advanced && g.pendingCapture.player === context.winner && g.pendingCapture.loser === loser && g.pendingCapture.territory === c.territory && g.players.find((p) => p.id === context.winner)?.faction === 'harkonnen')) && (!g.pendingTech || (g.pendingTech.player === context.winner && g.pendingTech.loser === loser && g.pendingTech.choices.length > 0 && new Set(g.pendingTech.choices).size === g.pendingTech.choices.length && g.pendingTech.choices.every((id) => ownedTech(g.techTokens, loser!).includes(id), ))) && (!g.pendingFaceDance || (g.pendingFaceDance.winner === context.winner && g.pendingFaceDance.territory === c.territory && g.players.find((p) => p.id === g.pendingFaceDance!.player) ?.faction === 'tleilaxu')) && (!g.pendingChoamBattleIncome || (Number.isSafeInteger(g.pendingChoamBattleIncome.amount) && g.pendingChoamBattleIncome.amount > 0 && g.players.find((p) => p.id === g.pendingChoamBattleIncome!.owner) ?.faction === 'choam')), 'The saved battle discard has inconsistent remaining battle effects.', ); const held = c.kind === 'battleResolved' ? c.cards : c.kept; const owner = c.kind === 'battleResolved' ? c.winner : c.player; requireRule( Array.isArray(held) && new Set(held).size === held.length && (owner === null ? held.length === 0 : c.combatants.includes(owner)) && held.every( (id) => typeof id === 'string' && !ids.has(id) && outside.filter((card) => card.id === id).length === 1 && !g.discard.some((card) => card.id === id) && g.players .find((p) => p.id === owner) ?.hand.some((card) => card.id === id), ), 'The cards kept for the battle continuation are inconsistent.', ); if (c.kind === 'battleCleanup') { requireRule( ['winner', 'moritani'].includes(c.source) && batch.cause === `battle:${c.source}` && Array.isArray(c.played) && new Set(c.played).size === c.played.length && c.played.length === c.kept.length + batch.entries.length && c.played.every( (id) => typeof id === 'string' && (ids.has(id) || c.kept.includes(id)), ) && (c.source === 'winner' ? c.player === context.winner : c.player !== context.winner) && batch.entries.every((e) => e.discardedBy === c.player) && (c.source !== 'moritani' || (!g.moritaniRetention && c.kept.length <= 1)), 'The saved battle card cleanup is inconsistent.', ); if (c.source === 'moritani') { const r = c.retention; const owner = g.players.find((p) => p.id === r?.owner); const loser = g.players.find((p) => p.id === c.player); requireRule( r && r.turn === g.turn && r.territory === c.territory && r.player === c.player && (r.source === 'nexus' ? r.owner === c.player && r.event === context.event && !byFaction(g, 'moritani') && context.nexusMoritaniRetention?.stage === 'complete' && context.nexusMoritaniRetention.kept === (c.kept[0] ?? null) && !!g.nexusCards?.cards && (!c.kept.length || g.nexusCards.cards.discard.includes('moritani')) : owner?.faction === 'moritani' && owner.ally === c.player && loser?.ally === owner.id && ['choose', 'response'].includes(r.stage)) && JSON.stringify(r.played) === JSON.stringify(c.played) && Array.isArray(r.eligible) && new Set(r.eligible).size === r.eligible.length && r.eligible.every((id) => r.played.includes(id)) && c.kept.every((id) => r.eligible.includes(id)), 'The consumed Moritani retention record is inconsistent.', ); } } else { requireRule( batch.cause === 'battle:mandatory' && c.result === context.result && c.winner === context.winner && ['normal', 'traitor', 'mutualTraitors', 'explosion'].includes( c.result, ) && (c.winner === null) === ['mutualTraitors', 'explosion'].includes(c.result) && !!c.casualties === (c.result === 'normal' && (g.advanced || !!g.pendingSukRescue || !!g.homeworlds || g.players.find((p) => p.id === c.winner)?.faction === 'ixians')), 'The mandatory battle discard cause is inconsistent.', ); if (c.casualties) { const { forces, dial, support, options } = c.casualties; const winner = g.players.find((p) => p.id === c.winner); const pool = winner ? combatArmy(g, winner.id, c.territory) : { normal: 0, elite: 0 }; const total = pool.normal + pool.elite; const elites = pool.elite; requireRule( winner && forces && validCombatForces(forces) && Number.isSafeInteger(forces.normal) && forces.normal >= 0 && Number.isSafeInteger(forces.elite) && forces.elite >= 0 && [1, 2].includes(forces.eliteStrength) && typeof forces.freeSupport === 'boolean' && (forces.eliteFreeSupport === undefined || typeof forces.eliteFreeSupport === 'boolean') && (forces.normalFixedHalf === undefined || typeof forces.normalFixedHalf === 'boolean') && forces.normal === total - elites && forces.elite === elites && Array.isArray(options) && options.length > 0 && JSON.stringify(options) === JSON.stringify(casualtyOptions(forces, dial, support)), 'The saved battle casualties do not match the committed dial, support and effective forces.', ); } } } else if (continuation?.kind === 'winnerMandatoryDiscard') { const c = continuation; requireRule(g.phase === 6 && g.lastBattleContext?.event === c.event && g.lastBattleContext.winner === c.player && g.lastBattleContext.territory === c.territory && batch.cause === 'battle:winnerMandatory' && !g.pendingWinnerDiscards && c.commitment?.signature === winnerDiscardSignature(c.commitment) && c.commitment.event === c.event && c.commitment.player === c.player && c.commitment.turn === g.turn && c.commitment.territory === c.territory && JSON.stringify(c.commitment.optional) === JSON.stringify(c.optional) && JSON.stringify(c.commitment.cards) === JSON.stringify(batch.entries.map((entry) => entry.card.id)) && batch.entries.every((entry) => entry.discardedBy === c.player && entry.publicFace) && Array.isArray(c.optional) && new Set(c.optional).size === c.optional.length && c.optional.every((id) => getPlayer(g, c.player).hand.some((card) => card.id === id)), 'The mandatory winning card discard no longer matches its resolved battle.'); } else if (continuation?.kind === 'discoveryStash') { const c = continuation; discoveryIntegrity(g); requireRule(batch.cause === 'discovery:stash' && batch.entries.length === 1 && batch.entries[0].discardedBy === c.owner && batch.entries[0].publicFace && batch.entries[0].card.id === c.card, 'The Treachery Card Stash has lost its chosen discard.'); } else if (continuation?.kind === 'nexusChoamTrade') { const c = continuation; nexusChoamTradeIntegrity(g); requireRule(batch.cause === 'nexus:choamTrade' && batch.entries.length === 1 && batch.entries[0].discardedBy === c.owner && batch.entries[0].publicFace && batch.entries[0].card.id === c.card && batch.entries[0].card.kind === 'worthless', 'The CHOAM Nexus trade has changed its discarded Worthless card.'); } else if (continuation?.kind === 'nexusChoamBetrayal') { const c = continuation; nexusChoamBetrayalIntegrity(g); requireRule(batch.cause === 'nexus:choamBetrayal' && batch.entries.length === 1 && batch.entries[0].discardedBy === c.target && !batch.entries[0].publicFace && batch.entries[0].card.id === c.card && g.nexusChoamBetrayals?.at(-1)?.event === c.event, 'The CHOAM Nexus Betrayal has changed its random physical discard.'); } else if (continuation?.kind === 'kaitainDiscard') { const c = continuation; requireRule(g.phase === 3 && g.biddingEnd?.event === c.event && getPlayer(g, c.owner).faction === 'emperor' && batch.cause === 'kaitain' && batch.entries.length > 0 && c.cost === 2 * batch.entries.length && Number.isSafeInteger(c.spiceAfter) && c.spiceAfter >= 0 && getPlayer(g, c.owner).spice === c.spiceAfter && batch.entries.every((entry) => entry.discardedBy === c.owner && !entry.publicFace), 'The committed Kaitain discard no longer matches its payment and closing opportunity.'); } else throw new RuleError('Unknown saved discard continuation.'); } /** Bounded public single-card reaction after clean completed effects. Battle * winner cleanup may resume only without other pending battle rewards; final * flight arrival excludes factions that open intrusion or entry responses. */ function semutaOfferSupported( g: Game, continuation: NonNullable['continuation'], entries: FreshDiscardBatch['entries'], ) { if (continuation.kind !== 'ordinaryCardDiscard' && continuation.kind !== 'nullentropyDiscard' && continuation.kind !== 'distransDiscard' && continuation.kind !== 'saphoMovementDiscard' && continuation.kind !== 'saphoAggressorDiscard' && continuation.kind !== 'saphoAuctionDiscard' && continuation.kind !== 'saphoBattleOrderDiscard' && continuation.kind !== 'residualPoisonDiscard' && continuation.kind !== 'karamaCharityDiscard' && continuation.kind !== 'karamaInflationDiscard' && continuation.kind !== 'karamaBgCharityDiscard' && continuation.kind !== 'karamaPurchaseDiscard' && continuation.kind !== 'karamaPaymentDiscard' && continuation.kind !== 'thumperDiscard' && continuation.kind !== 'amalDiscard' && continuation.kind !== 'ixAllyCard' && continuation.kind !== 'choamSaleDiscard' && continuation.kind !== 'choamKulonDiscard' && continuation.kind !== 'choamLaLaLaDiscard' && continuation.kind !== 'choamBalisetDiscard' && continuation.kind !== 'choamGamontDiscard' && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'battleResolved' && !(continuation.kind === 'ornithopterDiscard' && (continuation.source === 'end' || (continuation.source === 'move' && !g.players.some(p => p.faction === 'beneGesserit' || p.faction === 'ecaz' || p.faction === 'moritani') && !g.pendingArrivalOverlap))) && !(continuation.kind === 'truthtranceDiscard' && continuation.consumed.source === undefined && continuation.remaining === null && continuation.promise === null)) return false; if (continuation.kind === 'battleResolved' && !g.lastBattleContext?.mandatoryDiscard) return false; if ((continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved') && (g.pendingAuditor || g.pendingCapture || g.pendingTech || g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingWinnerDiscards || g.moritaniRetention || g.pendingIxSubstitution)) return false; if ((continuation.kind === 'distransDiscard' || continuation.kind === 'truthtranceDiscard' || continuation.kind === 'saphoMovementDiscard' || (continuation.kind === 'ornithopterDiscard' && continuation.source === 'move')) && (g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove || g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false; if (continuation.kind === 'saphoAggressorDiscard' && (!g.battle || g.pendingAuditor || g.pendingCapture || g.pendingTech || g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingWinnerDiscards || g.moritaniRetention || g.pendingIxSubstitution)) return false; if (continuation.kind === 'residualPoisonDiscard' && (!g.battle?.preLeader || g.battle.preLeader.closed || g.battle.preLeader.ready.length > 0 || g.battle.preparation || g.battle.prescience || g.battle.nexusInspection || g.battle.truthPromises?.length || g.pendingAuditor || g.pendingCapture || g.pendingTech || g.pendingFaceDance || g.pendingChoamBattleIncome || g.pendingWinnerDiscards || g.moritaniRetention || g.pendingIxSubstitution)) return false; if (continuation.kind === 'thumperDiscard' && (g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 || g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false; return g.semutaPreview === true && g.status === 'playing' && g.players.some(p => p.faction === 'richese') && entries.length > 0 && (continuation.kind === 'ixAllyCard' ? entries.length === 1 && !entries[0].publicFace : continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved' ? entries.every(entry => entry.publicFace) : entries.length === 1 && entries[0].publicFace) && (continuation.kind === 'karamaCharityDiscard' || continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaBgCharityDiscard' ? ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '') && !continuation.resume.decision && !continuation.resume.pendingKarama && !continuation.resume.phaseOpening && !g.pendingKarama && (!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response)) : true) && (continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved' || continuation.kind === 'ixAllyCard' || continuation.kind === 'choamSaleDiscard' || continuation.kind === 'choamKulonDiscard' || continuation.kind === 'choamLaLaLaDiscard' || continuation.kind === 'choamBalisetDiscard' || continuation.kind === 'choamGamontDiscard' || (continuation.kind === 'karamaCharityDiscard' || continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaBgCharityDiscard' || (!continuation.resume.response && !continuation.resume.decision && !continuation.resume.pendingKarama && (continuation.kind === 'amalDiscard' ? continuation.resume.phaseOpening?.passed.length === 0 : !continuation.resume.phaseOpening)))) && !g.karamaShipping && (continuation.kind === 'ixAllyCard' ? !!g.auction && g.currentAuctionSale?.origin === 'normal' && continuation.sale.origin === 'normal' && !g.richeseAuction : continuation.kind === 'karamaPurchaseDiscard' || continuation.kind === 'karamaPaymentDiscard' ? !!g.auction && !g.currentAuctionSale && !g.richeseAuction : !g.auction) && (continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) && (continuation.kind === 'saphoAggressorDiscard' || continuation.kind === 'residualPoisonDiscard' || !g.battle) && (continuation.kind !== 'choamKulonDiscard' || (!g.ornithopter && !g.pendingChoamMove && !g.summonedWorm && g.wormRides.length === 0)) && (continuation.kind !== 'choamLaLaLaDiscard' || (!g.pendingRevival && !g.pendingChoamMove && !g.ornithopter)) && (continuation.kind !== 'choamBalisetDiscard' || (!g.pendingChoamMove && !g.ornithopter && !g.pendingArrivalOverlap)) && (continuation.kind !== 'choamGamontDiscard' || (!g.pendingChoamMove && !g.pendingArrivalOverlap && !g.pendingRevival && !g.ornithopter)) && !g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingRicheseGift && !g.pendingRichesePurchaseIncome && (continuation.kind === 'choamSaleDiscard' ? !!g.choamMarket && !g.choamMarket.sale && !g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale : !g.choamMarket) && !g.pendingChoamMarketGhola; } function semutaContext(g: Game, owner: Player): SemutaContext { const pending = g.pendingTreacheryDiscard!; return { batch: pending.batch, event: pending.batch.event, turn: g.turn, phase: g.phase, owner: owner.id, ownerHand: owner.hand, discard: g.discard, semutaId: SEMUTA_DRUG_ID, handLimit: handLimit(owner), incomingReservedSlots: 0, capacityPolicy: 'freeSlot', reservedTargetIds: [], }; } function semutaCandidates(g: Game, owner: Player) { const context = semutaContext(g, owner); const semuta = owner.hand.find(card => card.id === SEMUTA_DRUG_ID && richeseCardDefinition(card)?.card.effect === 'semutaDrug'); requireRule(semuta && !transferCardBlock(g, owner, semuta), 'This Semuta Drug is reserved for an unfinished transaction.'); try { return committedSemutaCandidates(context, { event: context.event, player: owner.id, semutaId: SEMUTA_DRUG_ID }); } catch { throw new RuleError('This fresh Semuta claim is not available.'); } } function projectedSemutaReaction(g: Game, me: Player) { const pending = g.pendingTreacheryDiscard; const reaction = pending?.reaction; if (!pending || !reaction) return null; if (reaction.stage === 'select') return { event: pending.batch.event, stage: 'select' as const, passed: true, canCommit: false, blocked: null, candidates: reaction.player === me.id ? semutaCandidates(g, me) : [] as Card[], }; const passed = reaction.passed.includes(me.id); const semuta = me.hand.find(card => card.id === SEMUTA_DRUG_ID && richeseCardDefinition(card)?.card.effect === 'semutaDrug'); const held = !!semuta; const blocked = !held || passed ? null : transferCardBlock(g, me, semuta!) ?? (me.hand.length >= handLimit(me) ? 'A free hand slot is required before taking a card with Semuta Drug.' : pending.batch.entries.every(entry => entry.discardedBy === me.id) ? 'No other player discarded a card in this fresh event.' : null); return { event: pending.batch.event, stage: 'offer' as const, passed, canCommit: held && !passed && !blocked, blocked, candidates: [] as Card[], }; } function stageTreacheryDiscard( g: Game, cause: string, entries: FreshDiscardBatch['entries'], continuation: NonNullable['continuation'], ) { requireRule(!g.pendingTreacheryDiscard, 'Finish the prior discard first.'); treacheryDiscardIntegrity(g); const sequence = (g.treacheryDiscardSequence ?? 0) + 1; g.treacheryDiscardSequence = sequence; g.pendingTreacheryDiscard = { sequence, batch: { event: `discard:${g.turn}:${g.phase}:${sequence}`, turn: g.turn, phase: g.phase, cause, entries: structuredClone(entries), }, ...(semutaOfferSupported(g, continuation, entries) ? { reaction: { stage: 'offer' as const, passed: [] as string[] } } : {}), continuation: structuredClone(continuation), }; treacheryDiscardIntegrity(g); } /** Retire a completed fresh discard before its typed continuation resumes. */ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string }, finalPass?: string) { treacheryDiscardIntegrity(g); const pending = g.pendingTreacheryDiscard; if (!pending) return; const next = pending.continuation; requireRule(!pending.reaction || (claim ? pending.reaction.stage === 'select' && pending.reaction.player === claim.player && pending.reaction.candidates.includes(claim.card) : pending.reaction.stage === 'offer' && finalPass !== undefined && pending.reaction.passed.length === g.players.length - 1 && g.players.some(p => p.id === finalPass) && !pending.reaction.passed.includes(finalPass)), 'Finish the fresh discard reaction before its continuation.'); const owner = claim ? getPlayer(g, claim.player) : null; const result = claim && owner ? resolveSemutaDrug(semutaContext(g, owner), claim.card) : null; // Retire before the Semuta transfer or any suffix can draw the fresh card. g.resolvedTreacheryDiscardSequence = pending.sequence; g.pendingTreacheryDiscard = null; if (result && owner) { owner.hand = result.ownerHand; g.discard = result.discard; log(g, `${owner.name} played Semuta Drug and recovered one freshly discarded Treachery Card. The acquired card stays private.`, { faction: owner.faction, name: 'Semuta Drug' }); } if (next.kind === 'winnerMandatoryDiscard') { finishWinner(g, getPlayer(g, next.player), next.territory, next.optional); return; } if (next.kind === 'discoveryStash') { g.discoveryStash!.stage = 'complete'; g.discoveryStash!.signature = discoveryStashSignature(g.discoveryStash!); return; } if (next.kind === 'nexusChoamTrade') { const record = g.nexusChoamTrades!.at(-1)!; record.stage = 'complete'; record.signature = nexusChoamTradeSignature(record); g.nexusChoamTradeLast = { event: record.event, stage: record.stage }; return; } if (next.kind === 'nexusChoamBetrayal') { const record = g.nexusChoamBetrayals!.at(-1)!; record.stage = 'complete'; record.signature = nexusChoamBetrayalSignature(record); g.nexusChoamBetrayalLast = { event: record.event, stage: record.stage }; return; } if (next.kind === 'kaitainDiscard') return; if (next.kind === 'ambassador') { const p = getPlayer(g, next.entry.beneficiary!); if (next.entry.effect === 'ixians') { const card = draw(g); requireRule(card, 'No replacement card is available.'); p.hand.push(card); log( g, `${p.name} discarded one Treachery card and drew its replacement through the Ixian Ambassador. The replacement remains private.`, { faction: p.faction, name: 'Ixian Ambassador' }, ); } // CHOAM's selected-count payout was already committed with its discard. g.pendingAmbassador = next.entry; finishAmbassador(g); } else if (next.kind === 'choamSaleDiscard') { resumeChoamMarket(g); } else if (next.kind === 'choamKulonDiscard') { completeChoamKulon(g, getPlayer(g, next.player)); } else if (next.kind === 'choamLaLaLaDiscard') { completeChoamLaLaLa(g, next.target); } else if (next.kind === 'choamBalisetDiscard') { completeChoamBaliset(g, next.target, next.territory); } else if (next.kind === 'choamGamontDiscard') { completeChoamGamont(g, getPlayer(g, next.player), next.target, next.location, 0); } else if (next.kind === 'nullentropyDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'ordinaryCardDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; if (g.pendingChoamMarketGhola?.discardSequence === pending.sequence) g.pendingChoamMarketGhola.stage = g.response ? 'income' : 'complete'; } else if (next.kind === 'truthtranceDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; g.truthtrance = next.remaining; } else if (next.kind === 'distransDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'saphoMovementDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; if (g.active !== g.movementRemaining?.[0]) movementTurn(g); } else if (next.kind === 'saphoAuctionDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; settleRicheseLot(g); } else if (next.kind === 'saphoBattleOrderDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'saphoAggressorDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'residualPoisonDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; reconcileChangedBattleInspections(g, 'the leader death'); const battle = g.battle!; if (battle.preLeader && !battle.preLeader.closed) battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player); } else if (next.kind === 'karamaCharityDiscard' || next.kind === 'karamaInflationDiscard' || next.kind === 'karamaBgCharityDiscard' || next.kind === 'karamaPurchaseDiscard' || next.kind === 'karamaPaymentDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; const use: KaramaUse = 'use' in next ? next.use : { kind: next.kind === 'karamaPurchaseDiscard' ? 'purchase' : 'auctionPayment' }; completeKarama(g, getPlayer(g, next.player), use); } else if (next.kind === 'amalDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'thumperDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; blowSpice(g, true); } else if (next.kind === 'ornithopterDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; if (next.source === 'move') finishMovedGroup(g, next.movement!); else finishMovementTurn(g, next.flight.player); } else if (next.kind === 'ixAllyCard') { const buyer = getPlayer(g, next.player); const card = draw(g); if (card) buyer.hand.push(card); log( g, `${buyer.name} discarded the purchased card and drew a replacement using the Ixian alliance.`, ); continueAuctionSale(g, next.free); } else if (next.kind === 'battleResolved') { continueResolvedBattle(g, next); } else if (next.kind === 'terrorDiscard') { g.pendingTerrorEntry = next.entry; continueTerrorDiscard(g, next.source); } else if (next.kind === 'terrorAtomics') { g.pendingTerrorEntry = next.entry; finishTerrorEntry(g); } else { finishBattle(g); } } function applySemutaReaction(g: Game, id: string, action: Action) { const pending = g.pendingTreacheryDiscard; const reaction = pending?.reaction; requireRule(pending && reaction && action.event === pending.batch.event, 'This fresh discard reaction is no longer available.'); const player = getPlayer(g, id); if (reaction.stage === 'offer') { requireRule(!reaction.passed.includes(id), 'You already passed this discard.'); if (action.type === 'semutaPass') { requireRule(Object.keys(action).sort().join(',') === 'event,type', 'Pass only the current fresh discard.'); if (reaction.passed.length + 1 === g.players.length) finishTreacheryDiscard(g, undefined, id); else reaction.passed.push(id); return; } requireRule(action.type === 'semutaCommit' && Object.keys(action).sort().join(',') === 'event,type', 'Choose whether to commit Semuta to this discard.'); const candidates = semutaCandidates(g, player).map(card => card.id); pending.reaction = { stage: 'select', player: id, candidates }; if (candidates.length === 1) finishTreacheryDiscard(g, { player: id, card: candidates[0] }); else log(g, `${player.name} committed Semuta Drug to this fresh discard and must choose one card privately.`, { faction: player.faction, name: 'Semuta Drug' }); return; } requireRule(action.type === 'semutaSelect' && Object.keys(action).sort().join(',') === 'card,event,type' && id === reaction.player && typeof action.card === 'string' && reaction.candidates.includes(action.card), 'Choose one committed Semuta card from this fresh discard.'); finishTreacheryDiscard(g, { player: id, card: action.card }); } function place(p: Player, t: string, s: number, n: number, elite = 0) { const k = location(t, s); p.forces[k] = (p.forces[k] ?? 0) + n; if (p.elites && elite) p.elites.forces[k] = (p.elites.forces[k] ?? 0) + elite; } function withdrawNativeReserves( g: Game, p: Player, amount: number, elite: number, selections?: NativeReserveSelections, ) { if (!g.homeworlds?.custody) { requireRule( selections === undefined, 'Homeworld source selection requires the Homeworld module.', ); p.reserves -= amount; if (p.elites) p.elites.reserves -= elite; return []; } const result = homeworldRule(() => quoteNativeReserveWithdrawal( homeworldContext(g), g.homeworlds!.custody!, p.id, { normal: amount - elite, elite }, selections, ), ); const changed = result.players.find((seat) => seat.id === p.id)!; p.reserves = changed.reserves; if (p.elites) p.elites.reserves = changed.eliteReserves; g.homeworlds.custody = result.state; // All Homeworld changes are committed here; the destination is on Arrakis. observeOccupation(g); return result.receipts; } function addRevivedReserves(g: Game, p: Player, amount: number, elite: number) { if (!g.homeworlds?.custody) { p.reserves += amount; if (p.elites) p.elites.reserves += elite; return; } const result = homeworldRule(() => quoteNativeRevivalDeposit( homeworldContext(g), g.homeworlds!.custody!, p.id, { normal: amount - elite, elite }, ), ); const changed = result.players.find((seat) => seat.id === p.id)!; p.reserves = changed.reserves; if (p.elites) p.elites.reserves = changed.eliteReserves; g.homeworlds.custody = result.state; observeOccupation(g); } function kill( g: Game, p: Player, key: string, n: number, battle = false, elite?: number, ) { const amount = Math.min(n, p.forces[key] ?? 0); if (p.elites) { const available = p.elites.forces[key] ?? 0; const lost = elite ?? Math.max(0, amount - ((p.forces[key] ?? 0) - available)); requireRule( lost >= 0 && lost <= available && lost <= amount && amount - lost <= (p.forces[key] ?? 0) - available, 'Invalid elite casualties.', ); p.elites.forces[key] = available - lost; if (!p.elites.forces[key]) delete p.elites.forces[key]; p.elites.tanks += lost; } p.forces[key] = (p.forces[key] ?? 0) - amount; if (!p.forces[key]) delete p.forces[key]; p.tanks += amount; if (battle) p.battleLosses += amount; } function homeworldSavedDecisions(g: Game): Decision[] { const continuation = g.pendingTreacheryDiscard?.continuation; return [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null, ].flatMap((context) => context?.decision ? [context.decision] : []); } function spiritualAdvisorMaximum(g: Game, player: string, destination = 'polar_sink') { return homeworldRule(() => homeworldSpiritualAdvisorLimit(g, player, destination)); } function homeworldMobilityIntegrity(g: Game) { if (!g.homeworlds?.custody) return; const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap((context) => { if (!context) return []; const pending = 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context.response, pending?.use?.kind === 'cancel' ? pending.use.response : null]; }); for (const response of responses) { if (response?.kind === 'advisor') { const destination = splitLocation(response.location ?? 'polar_sink:0'); integer(response.amount === undefined ? 1 : response.amount, 1, spiritualAdvisorMaximum(g, response.owner, destination.territory), 'Saved Spiritual Advisor forces'); } if (response?.kind === 'atreidesSpice') { const blocked = homeworldRule(() => homeworldMovementForesightBlock(g, response.owner)); requireRule(!blocked, blocked ?? 'Caladan prevents the saved foresight response.'); } if (response?.kind === 'mobileStronghold') { const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, response.owner)); requireRule(!blocked, blocked ?? 'Ix prevents the saved mobile stronghold response.'); } } } function homeworldDefenseIntegrity(g: Game) { const b = g.battle; const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldDefense'); if (!decisions.length && b?.homeworldDefensePassed === undefined) return; requireRule(g.status === 'playing' && g.phase === 6 && b?.revealed && b.territory.startsWith('homeworld:') && b.event, 'The Homeworld late-defense frame needs its current revealed battle.'); const voters = traitorVoters(g, b); const passed = b.homeworldDefensePassed ?? []; requireRule(Array.isArray(passed) && new Set(passed).size === passed.length && passed.every((id) => [b.attacker, b.defender].includes(id) && !voters.includes(id)), 'Homeworld late-defense passes must belong to distinct invading combatants.'); for (const d of decisions) { requireRule(d.event === b.event && [b.attacker, b.defender].includes(d.player) && !voters.includes(d.player) && !passed.includes(d.player) && voters.every((id) => b.traitorCalls[id] !== undefined), 'The saved Homeworld late-defense choice does not match its battle owner and timing.'); const owner = getPlayer(g, d.player); const card = owner.hand.find(isPortableSnooper); requireRule(card && !b.lateDefense?.[owner.id] && !portableSnooperPlanBlock(b.plans[owner.id], owner.hand, card, b.voice?.target === owner.id ? b.voice : undefined), 'The saved late-defense opportunity needs its held legal Portable Snooper.'); } } function faceDanceReturnBlock(g: Game, winner: string): string | null { return g.advanced && g.homeworlds && getPlayer(g, winner).faction === 'emperor' ? 'Face Dance returning the Emperor’s army awaits the Kaitain/Salusa reserve-placement ruling.' : null; } function homeworldSubstitutionIntegrity(g: Game) { const pending = g.pendingIxSubstitution; if (!pending?.territory.startsWith('homeworld:')) { requireRule(!pending?.homeworld, 'A native substitution receipt requires a Homeworld battle.'); return; } const receipt = pending.homeworld; const p = getPlayer(g, pending.player); const context = g.lastBattleContext; requireRule(receipt && p.faction === 'ixians' && g.phase === 6 && !g.battle && context?.turn === g.turn && context.territory === pending.territory && context.winner === p.id && context.result === 'normal', 'The Homeworld substitution needs its completed Ixian battle receipt.'); const pool = combatArmy(g, p.id, pending.territory); requireRule(receipt.pool && pool.normal === receipt.pool.normal && pool.elite === receipt.pool.elite && p.elites!.tanks === receipt.eliteTanks && p.tanks - p.elites!.tanks === receipt.normalTanks && p.battleLosses === receipt.battleLosses && Number.isSafeInteger(receipt.cyborgsLost) && receipt.cyborgsLost > 0 && Object.keys(pending.losses).length === 1 && pending.losses[pending.territory] === receipt.cyborgsLost, 'The Homeworld substitution no longer matches its exact survivors and Tanks.'); const maps = [pending.sources, pending.recover]; requireRule(maps.every((map) => map === undefined) || maps.every((map) => map && Object.keys(map).length === 1 && Object.hasOwn(map, pending.territory)), 'A Homeworld substitution must use its exact battle location.'); if (pending.sources && pending.recover) { const amount = pending.sources[pending.territory]; requireRule(amount === pending.recover[pending.territory], 'The substitution must exchange matching physical counts.'); quoteHomeworldSubstitution(homeworldLossContext(g), g.homeworlds!.custody!, {location: pending.territory, player: p.id, amount, cyborgsLost: receipt.cyborgsLost}); } else quoteHomeworldLoss(g, p.id, pending.territory, {normal: 0, elite: 0}); } function currentHomeworldBattleRules(g: Game, to: string) { const home = homeworldBattleLocation(g, to); return home ? { native: home.native, card: home.card, side: home.side, nativeForces: { ...home.forces[home.native] } } : null; } function quoteHomeworldLoss(g: Game, player: string, to: string, losses: HomeworldForces) { requireRule(g.homeworlds?.custody, 'Homeworld casualties require saved custody.'); return quoteHomeworldCombatLoss(homeworldLossContext(g), g.homeworlds.custody, { location: to, player, losses }); } function homeworldLossContext(g: Game) { return { advanced: g.advanced, players: g.players.map((p) => { const total = Object.values(p.forces).reduce((a, b) => a + b, 0); const elite = Object.values(p.elites?.forces ?? {}).reduce((a, b) => a + b, 0); return { id: p.id, faction: p.faction, reserves: p.reserves, eliteReserves: p.elites?.reserves ?? 0, tanks: p.tanks, eliteTanks: p.elites?.tanks ?? 0, battleLosses: p.battleLosses, boardForces: { normal: total - elite, elite } }; }) }; } function commitHomeworldLoss(g: Game, player: string, to: string, losses: HomeworldForces) { const quote = quoteHomeworldLoss(g, player, to, losses); commitHomeworldResources(g, quote); } function commitHomeworldResources(g: Game, quote: Pick, 'custody' | 'players'>) { g.homeworlds!.custody = quote.custody; for (const update of quote.players) { const p = getPlayer(g, update.id); p.reserves = update.reserves; p.tanks = update.tanks; p.battleLosses = update.battleLosses; if (p.elites) { p.elites.reserves = update.eliteReserves; p.elites.tanks = update.eliteTanks; } } } /** The location's exact typed pool remains fixed while casualty/card choices * wait. No population, support, death or payment is replayed on restoration. */ function homeworldBattleLossIntegrity(g: Game) { const pending = g.homeworldBattleLoss; const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldExplosion' || (d.kind === 'battleLosses' && d.territory.startsWith('homeworld:'))); if (!pending) { requireRule(!decisions.length, 'The saved Homeworld casualty receipt is missing.'); return; } const context = g.lastBattleContext; requireRule(g.phase === 6 && !g.battle && context && context.turn === g.turn && context.event === pending.event && context.territory === pending.territory && pending.territory.startsWith('homeworld:') && ((pending.kind === 'winner' && context.result === 'normal' && context.winner === pending.player) || (pending.kind === 'explosion' && context.result === 'explosion' && context.winner === null)), 'The Homeworld casualty receipt does not match its resolved battle.'); const pool = combatArmy(g, pending.player, pending.territory); requireRule(pending.pool && Object.keys(pending.pool).length === 2 && pool.normal === pending.pool.normal && pool.elite === pending.pool.elite, 'The Homeworld casualty receipt does not match its exact physical pool.'); let options: HomeworldForces[]; if (pending.kind === 'explosion') { const rules = currentHomeworldBattleRules(g, pending.territory)!; requireRule(rules.native === pending.player && !pending.commitment, 'Native explosion casualties belong to this Homeworld’s native faction.'); options = quoteHomeworldBattleRules(pending.territory, g.players, rules).explosion.options; } else { const commitment = pending.commitment; requireRule(commitment && commitment.forces.normal === pool.normal && commitment.forces.elite === pool.elite, 'The Homeworld winner needs its committed physical forces.'); options = casualtyOptions(commitment.forces, commitment.dial, commitment.support).map(({normal, elite}) => ({normal, elite})); } requireRule(options.length > 0 && JSON.stringify(options) === JSON.stringify(pending.options), 'The Homeworld casualty choices do not match the committed outcome.'); for (const choice of options) quoteHomeworldLoss(g, pending.player, pending.territory, choice); for (const decision of decisions) { if (decision.kind !== 'homeworldExplosion' && decision.kind !== 'battleLosses') continue; requireRule(decision.player === pending.player && decision.territory === pending.territory && (decision.kind === 'homeworldExplosion' ? pending.kind === 'explosion' && decision.event === pending.event && JSON.stringify(decision.pool) === JSON.stringify(pending.pool) : pending.kind === 'winner') && JSON.stringify(decision.options.map(({normal, elite}) => ({normal, elite}))) === JSON.stringify(options), 'The owned Homeworld casualty decision does not match its receipt.'); } } function settleHomeworldExplosion(g: Game, choice: HomeworldForces, automatic: boolean) { homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); const pending = g.homeworldBattleLoss!; requireRule(pending.kind === 'explosion' && pending.options.some((p) => p.normal === choice.normal && p.elite === choice.elite), 'Choose a native explosion casualty allocation.'); commitHomeworldLoss(g, pending.player, pending.territory, choice); observeOccupation(g); const player = getPlayer(g, pending.player); log(g, `${player.name} lost ${choice.normal} normal and ${choice.elite} special forces on ${combatLocationName(g, pending.territory)}. The remaining native forces survived the Lasgun–shield explosion.${automatic ? ' The only physical allocation was applied automatically.' : ''}`, automatic ? { faction: player.faction, name: 'Native explosion casualties' } : undefined); g.homeworldBattleLoss = null; finishBattle(g); } function killTerritory( g: Game, p: Player, t: string, n = Infinity, battle = false, noFieldCause?: NoFieldRevealCause, ) { if (t.startsWith('homeworld:')) { requireRule(battle && n === Infinity, 'Homeworld force removal requires an exact combat casualty allocation.'); commitHomeworldLoss(g, p.id, t, combatArmy(g, p.id, t)); return; } if (p.noField?.deployed?.location.territory === t) { requireRule( noFieldCause, 'This removal of a concealed No-Field awaits its specific reveal and casualty rules.', ); revealPlayerNoField(g, p, noFieldCause); } for (const key of Object.keys(p.forces)) { if (splitLocation(key).territory !== t) continue; const lost = Math.min(n, p.forces[key]); kill(g, p, key, lost, battle); n -= lost; } } function combatForces( g: Game, p: Player, t: string, opponent: Player, ): CombatForces { const marker = p.noField?.deployed; const prospective = marker?.location.territory === t ? Math.min( p.reserves, p.noField!.tokens.find((token) => token.id === marker.tokenId)!.value, ) : 0; const army = combatArmy(g, p.id, t); const physical = army.normal + army.elite; const elite = army.elite; return { normal: physical + prospective - elite, ...(nexusSardaukarEffective(g,p.id,t) ? {temporaryElite:nexusSardaukarEffective(g,p.id,t)} : {}), ...(homeworldSardaukarFreeSupport(g, p.id) ? { eliteFreeSupport: true } : {}), normalFixedHalf: p.faction === 'ixians' && !nexusSuboidsActive(g, g.nexusSuboidHistory, p.id), ...(nexusSuboidsActive(g, g.nexusSuboidHistory, p.id) ? { normalFreeSupport: true } : {}), elite, eliteStrength: (!g.advanced && p.faction !== 'ixians') || (g.battle?.eliteBlocked?.includes(p.id) && !(g.advanced && p.faction === 'ixians')) || (p.faction === 'emperor' && opponent.faction === 'fremen') ? 1 : 2, freeSupport: !g.advanced || (p.faction === 'fremen' && !g.battle?.fremenSupportBlocked), }; } function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) { if (t.startsWith('homeworld:')) { commitHomeworldLoss(g, p.id, t, { normal: losses.normal, elite: losses.elite }); return losses.elite ? { [t]: losses.elite } : {}; } const lostCyborgs: Record = {}; let normal = losses.normal, elite = losses.elite; for (const key of Object.keys(p.forces).filter( (key) => splitLocation(key).territory === t, )) { const e = Math.min(elite, p.elites?.forces[key] ?? 0); const n = Math.min(normal, p.forces[key] - (p.elites?.forces[key] ?? 0)); kill(g, p, key, n + e, true, e); if (e) lostCyborgs[key] = e; normal -= n; elite -= e; } requireRule( normal === 0 && elite === 0, 'The selected casualties are not available.', ); return lostCyborgs; } function baseStartProfileBlock(g: Game): string | null { if (g.homeworlds) return 'Homeworld gameplay is still being implemented.'; if (g.nexusCards) return 'Nexus card effects are still being implemented.'; if (g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker) return 'Discoveries are still being implemented.'; if (g.leaderSkills) return 'Leader Skills are still being implemented.'; if (g.ecazTreachery) return 'Ecaz Treachery Cards are still being implemented.'; if (g.mentatQuestionPreview || g.moritaniAssassinatePreview || g.moritaniAssassinate) return 'Separate development profiles cannot be enabled by a normal start.'; if (!g.players.every(p => FACTIONS.some(entry => entry.id === p.faction && entry.expansion === 'base'))) return 'Expansion factions are still being implemented.'; if (g.expansions.length !== 0) return 'Expansion rules are still being implemented.'; return null; } function advancedPreviewIntegrity(g: Game) { if (g.advancedPreview === undefined) return; requireRule(g.advancedPreview === true && g.advanced === true && g.status !== 'lobby' && !baseStartProfileBlock(g), 'The saved Advanced preview no longer matches its supported rules profile.'); } function requireFreshSetup(g: Game, allowIxElites = false) { requireRule(g.status === 'lobby' && !g.setupStage && g.turn === 1 && g.phase === 0 && g.advancedPreview === undefined, 'Start only from a fresh lobby.'); requireRule(g.players.length >= 2 && g.players.length <= 6 && new Set(g.players.map(p => p.id)).size === g.players.length && new Set(g.players.map(p => p.faction)).size === g.players.length && g.players.some(p => p.id === g.host) && g.players.every(p => p.id && p.ready), 'Start requires two through six distinct ready players and an existing host.'); requireRule(!g.deck.length && !g.discard.length && !g.spiceDeck.length && !g.spiceDiscard.some(pile => pile.length) && !g.traitorReserve?.length && g.ecazLoyalty === undefined && !g.decision && !g.response && !g.battle && !g.winner.length && g.players.every(p => !p.hand.length && !p.traitors.length && !p.traitorChoices.length && Object.keys(p.forces).length === 0 && p.reserves === 20 && p.tanks === 0 && p.spice === 0 && p.moved === 0 && !p.shipped && !p.prediction && !p.advisorSetup && (!p.elites || (allowIxElites && p.faction === 'ixians' && p.elites.reserves === 7 && p.elites.tanks === 0 && p.elites.revived === 0 && Object.keys(p.elites.forces).length === 0))), 'Starting setup cannot redeal or replace existing game pieces.'); requireRule(g.players.every(p => { const native = leaders(p.faction); return p.leaders.length === native.length && native.every(expected => { const actual = p.leaders.find(leader => leader.id === expected.id); return actual && Object.keys(actual).length === Object.keys(expected).length && Object.entries(expected).every(([key, value]) => actual[key as keyof Leader] === value); }) && p.ally === null && p.allySinceTurn === undefined && p.bribes === 0 && p.revived === 0 && (p.freeForcesRevived ?? 0) === 0 && p.leaderRevived === false && p.revivalCycle === 0 && p.battleLosses === 0 && !p.specialKaramaUsed && p.kwisatz === undefined && p.charityTurn === undefined && p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined && p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined && p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined && p.fremenNexusWormBlockedTurn === undefined && p.atomicsHandLimitPenalty === undefined && p.faceDancers === undefined && p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined; }), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.'); requireRule(Object.keys(g.playerPositions ?? {}).every(id => g.players.some(p => p.id === id)), 'Player circles must belong to seated players.'); normalizedPlayerPositions(g); } function requireFreshBaseRuntime(g: Game) { requireRule((g.botsPending === undefined || g.botsPending === false) && g.botNextActionAt === undefined, 'Start with no pending bot work or prior action deadline.'); const fresh = createGame(g.code, getPlayer(g, g.host), g.advanced); // These are the live lobby settings and storage metadata. Module admission and // unowned optional components are checked separately; gameplay fields stay fresh. const configuration = new Set(['code', 'version', 'host', 'players', 'playerPositions', 'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards', 'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery']); const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[]; requireRule(keys.every(key => configuration.has(key) || JSON.stringify(g[key]) === JSON.stringify(fresh[key])), 'Starting a base table cannot retain existing game resources, histories or continuations.'); } function start(g: Game, advancedPreview = false) { requireRule(g.players.length >= 2, 'At least two players are needed.'); requireRule( g.players.every((p) => p.ready), 'Every player must be ready.', ); requireRule(typeof g.advanced === 'boolean' && (!g.advanced || advancedPreview), 'Advanced rules are unfinished. Explicitly choose Start Advanced preview to continue.'); requireRule(!advancedPreview || g.advanced, 'Choose Advanced preview rules before starting that preview.'); const blocked = baseStartProfileBlock(g); requireRule(!blocked, blocked ?? 'This rules profile is unavailable.'); requireFreshSetup(g); requireRule(!g.techTokens || JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens()), 'Start with unused Tech Tokens before assigning their initial owners.'); requireRule(!g.strongholdCards || JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards()), 'Start with unused Stronghold Cards before assigning ownership.'); requireFreshBaseRuntime(g); initializeSetup(g); if (advancedPreview) g.advancedPreview = true; } function skillRule(operation: () => T): T { try { return operation(); } catch (error) { if (error instanceof LeaderSkillError) throw new RuleError(error.message); throw error; } } function skillEligibleLeaders(g: Game, owner: string): string[] { return getPlayer(g, owner) .leaders.filter( (leader) => !leader.dead && !leader.capturedBy && !leader.gholaBy && !isAuditorLeader(leader) && !g.leaderSkills?.assignments.some( (assignment) => assignment.leader === leader.id, ), ) .map((leader) => leader.id); } const ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK = 'Suk Graduate is unavailable to Advanced Atreides while the Kwisatz Haderach loss-count ruling is pending.'; function leaderSkillAssignmentUnavailable( g: Game, owner: string, skill: string, ): string | null { return g.advanced && getPlayer(g, owner).faction === 'atreides' && skill === 'suk-graduate' ? ADVANCED_ATREIDES_SUK_ASSIGNMENT_BLOCK : null; } function moritaniAssassinateModeSupported(g: Game) { return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' && g.players.some(p => p.faction === 'moritani') && g.players.every(p => ['moritani','atreides','beneGesserit','guild','emperor','fremen'].includes(p.faction)) && !g.nexusCards && !g.leaderSkills && !g.strongholdCards && !g.homeworlds && !g.discoveryEnabled && !g.discoveries && !g.techTokens; } function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) { const p = getPlayer(g,receipt.owner), opponent = getPlayer(g,receipt.opponent); return {state:g.moritaniAssassinate!,receipt,held:p.traitors, normallyRevealed:p.revealedTraitors ?? [],leaders:opponent.leaders}; } function moritaniAssassinateIntegrity(g: Game) { const state = g.moritaniAssassinate, resume = g.moritaniAssassinateResume; requireRule(g.moritaniAssassinatePreview === undefined || g.moritaniAssassinatePreview === true, 'Invalid assassination preview setting.'); if (!state) { requireRule(!g.moritaniAssassinatePreview && !resume && !g.moritaniAssassinateCallEvents && !g.lastBattleContext?.moritaniAssassinate && g.decision?.kind !== 'moritaniAssassinate', 'The assassination preview lost its original state.'); return; } requireRule(g.moritaniAssassinatePreview === true && moritaniAssassinateModeSupported(g) && state.owner === byFaction(g,'moritani')?.id, 'This assassination preview no longer matches its original faction profile.'); nexusRule(() => validateMoritaniAssassinate(state,{turn:g.turn,held:getPlayer(g,state.owner).traitors, removed:Array.isArray(state.opportunities) ? state.opportunities.filter(r => r?.stage === 'replaced').map(r => r.card!) : []})); const calls = g.moritaniAssassinateCallEvents; requireRule(Array.isArray(calls) && calls.every(event => typeof event === 'string' && event.length > 0) && new Set(calls).size === calls.length && state.normalTraitorCall === (calls.length > 0), 'The assassination normal-traitor-call guard lost its original event record.'); for (const receipt of state.opportunities) { const opponent = getPlayer(g,receipt.opponent); requireRule(receipt.turn <= g.turn && opponent.faction === receipt.faction && receipt.owner === state.owner && TERRITORIES.some(t => t.id === receipt.territory), 'The assassination history no longer matches its original battle participants.'); } const pending = state.opportunities.filter(r => r.stage === 'choice'); requireRule(pending.length <= 1, 'Finish the current assassination opportunity first.'); const obligation = g.lastBattleContext?.moritaniAssassinate; if (obligation) { const receipt = state.opportunities.find(r => r.event === obligation.event); requireRule(receipt && receipt.signature === obligation.signature && receipt.event === g.lastBattleContext!.event, 'The completed battle lost or changed its assassination obligation.'); } if (pending.length) { const r = pending[0]; requireRule(g.status === 'playing' && g.phase === 6 && g.turn === r.turn && !g.battle && g.lastBattleContext?.event === r.event && g.lastBattleContext.winner === r.opponent && g.lastBattleContext.result === 'normal' && g.lastBattleContext.territory === r.territory && obligation?.event === r.event, 'The assassination choice lost its resolved battle.'); requireRule(resume && resume.event === r.event && resume.continuation.event === r.event && resume.continuation.winner === r.opponent && resume.continuation.territory === r.territory && resume.continuation.result === 'normal' && JSON.stringify(resume.continuation.combatants) === JSON.stringify(g.lastBattleContext.combatants) && resume.signature === JSON.stringify(resume.continuation) && resume.signature === obligation.continuation, 'The assassination choice lost or changed its saved cleanup.'); const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'moritaniAssassinate'); requireRule(decisions.length === 1 && decisions[0].player === r.owner && (decisions[0] as Extract).event === r.event, 'The assassination choice lost its owning decision.'); } else requireRule(!resume && g.decision?.kind !== 'moritaniAssassinate', 'A settled assassination cannot retain its decision.'); if (g.status === 'playing' || g.status === 'finished') { const retired = state.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!); const physical = [...(g.traitorReserve ?? []),...g.players.flatMap(p => p.traitors),...retired].sort(); requireRule(JSON.stringify(physical) === JSON.stringify([...nexusTraitorUniverse(g)].sort()), 'Assassination must preserve every physical Traitor Card in hand, deck or face-up set-aside custody.'); for (const r of state.opportunities.filter(r => r.stage === 'revealed')) requireRule(getPlayer(g,r.owner).traitors.includes(r.card!), 'The revealed assassination card must remain held until Mentat replacement.'); const replacements = new Set(); for (const [index,r] of state.opportunities.entries()) if (r.stage === 'replaced') { const laterUses = state.opportunities.slice(index+1).filter(next => next.card === r.replacement && (next.stage === 'revealed' || next.stage === 'replaced')); const ownedReplacement = laterUses.length ? laterUses.every(next => next.turn > r.turn) : getPlayer(g,r.owner).traitors.includes(r.replacement!); requireRule(!replacements.has(r.replacement!) && ownedReplacement, 'Each assassination replacement must stay with Moritani or be consumed by its later assassination.'); replacements.add(r.replacement!); } } } function syncMoritaniAssassinateReceipt(g: Game, receipt: MoritaniAssassinateReceipt) { receipt.signature = moritaniAssassinateSignature(receipt); if (g.lastBattleContext?.event === receipt.event) g.lastBattleContext.moritaniAssassinate = {...g.lastBattleContext.moritaniAssassinate,event:receipt.event,signature:receipt.signature}; } function recordMoritaniAssassinateOpportunity(g: Game, b: Battle, winner: Player | undefined, loser: Player | null) { const state = g.moritaniAssassinate; if (!state || state.normalTraitorCall || !winner || loser?.id !== state.owner || state.opportunities.some(r => r.faction === winner.faction && ['revealed','replaced'].includes(r.stage))) return; const opposingLeader = winner.leaders.find(l => l.id === b.plans[winner.id].leader); const trigger = {advanced:g.advanced,owner:state.owner,ownerFaction:loser.faction,opponent:winner.id, faction:winner.faction,event:g.lastBattleContext!.event,turn:g.turn,territory:b.territory, opposingLeader:opposingLeader?.id ?? null,opposingLeaderSurvived:!!opposingLeader && !opposingLeader.dead, normalBattle:g.lastBattleContext!.result === 'normal',lostBattle:true, traitorCalled:Object.values(b.traitorCalls).some(Boolean)}; if (!moritaniAssassinateTrigger(trigger)) return; const receipt = createMoritaniAssassinateOpportunity(trigger); state.opportunities.push(receipt); syncMoritaniAssassinateReceipt(g,receipt); } function projectedMoritaniAssassinate(g: Game, viewer: string) { const state = g.moritaniAssassinate; if (!state) return null; const pending = state.opportunities.find(r => r.stage === 'choice'); const own = viewer === state.owner; const choices = pending && own ? nexusRule(() => moritaniAssassinateChoices(moritaniAssassinateContext(g,pending))) : null; return {owner:state.owner,blocked:own && state.normalTraitorCall ? 'Assassinate Leaders after a normal traitor call awaits clarification of the printed loss-of-advantage rule.' : null, pending:pending ? {event:pending.event,opponent:pending.opponent,territory:pending.territory, cards:choices?.cards ?? [],blocked:choices?.blocked ?? null} : null, history:state.opportunities.filter(r => r.stage === 'revealed' || r.stage === 'replaced').map(r => ({ event:r.event,turn:r.turn,opponent:r.opponent,faction:r.faction,territory:r.territory, card:r.card!,bounty:r.bounty,stage:r.stage as 'revealed'|'replaced', }))}; } function actMoritaniAssassinate(g: Game, decision: Extract, action: Action) { const state = g.moritaniAssassinate!, r = state.opportunities.find(r => r.stage === 'choice')!; requireRule(r && action.event === r.event && decision.event === r.event && decision.player === r.owner, 'This assassination opportunity has already changed.'); const resume = g.moritaniAssassinateResume!; requireRule(resume?.event === r.event, 'The original assassination cleanup is missing.'); if (action.decline === true) { requireRule(Object.keys(action).every(k => ['type','event','decline'].includes(k)), 'Continue without selecting an assassination card.'); r.stage = 'declined'; log(g, `${getPlayer(g,r.owner).name} finished the Assassinate Leaders opportunity without revealing a card.`, {faction:'moritani',name:'Assassinate Leaders'}); } else { requireRule(Object.keys(action).every(k => ['type','event','card'].includes(k)), 'Choose one assassination Traitor Card or continue.'); const quote = nexusRule(() => quoteMoritaniAssassinate(moritaniAssassinateContext(g,r),stringField(action.card))); const target = getPlayer(g,r.opponent).leaders.find(l => l.id === quote.leader)!; if (quote.kill) {target.dead = true;target.deaths++;delete target.usedAt;} const owner = getPlayer(g,r.owner); requireRule(Number.isSafeInteger(owner.spice + quote.bounty), 'Assassination income exceeds the supported spice balance.'); owner.spice += quote.bounty; owner.revealedTraitors = [...new Set([...(owner.revealedTraitors ?? []),quote.leader])]; Object.assign(r,quote.receipt); log(g, `${owner.name} revealed ${target.name} as a traitor of ${faction(r.faction).name}. ${quote.kill ? `${target.name} was sent to the Tanks and ${owner.name} collected ${quote.bounty} spice from the bank.` : 'That leader was already in the Tanks; no new death or spice was awarded.'} This uses Assassinate Leaders against that faction for the game. The revealed card will be set aside and replaced during Mentat Pause.`, {faction:'moritani',name:'Assassinate Leaders'}); } syncMoritaniAssassinateReceipt(g,r); delete g.moritaniAssassinateResume; continueResolvedBattle(g,resume.continuation); } function replaceMoritaniAssassinationTraitors(g: Game) { const state = g.moritaniAssassinate; if (!state) return; for (const r of state.opportunities.filter(r => r.stage === 'revealed')) { const owner = getPlayer(g,r.owner); requireRule(r.turn === g.turn && owner.traitors.includes(r.card!) && (g.traitorReserve?.length ?? 0) > 0, 'Mentat replacement needs the original revealed card and one remaining physical Traitor Card.'); owner.traitors = owner.traitors.filter(id => id !== r.card); r.replacement = g.traitorReserve!.shift()!; owner.traitors.push(r.replacement); r.stage = 'replaced'; syncMoritaniAssassinateReceipt(g,r); log(g, `${owner.name} set the revealed ${faction(r.faction).name} Traitor Card aside face up and drew one private replacement during Mentat Pause. The set-aside card records this faction's used assassination.`, {faction:'moritani',name:'Assassination replacement'}); } } function leaderSkillsIntegrity(g: Game) { moritaniAssassinateIntegrity(g); bureaucratPaymentIntegrity(g); mentatQuestionIntegrity(g); diplomatDefenseIntegrity(g); diplomatRetreatIntegrity(g); harassAllocationIntegrity(g); const noFieldShipment = leaderSkillNoFieldIntegrity(g); sandmasterIntegrity(g); sukRescueIntegrity(g); ixSubstitutionIntegrity(g); rihaniIntegrity(g); smugglerBattleIntegrity(g); for (const [owner, plan] of Object.entries(g.battle?.plans ?? {})) { if (plan.bankerSpice === undefined) continue; const p = getPlayer(g, owner); if (plan.bankerSpice) requireRule(spiceBankerModeSupported(g), 'Spice Banker with expansion modules is still being integrated.'); const leader = controlledLeaders(g, p).find(l => l.id === plan.leader && !l.dead); requireRule([g.battle!.attacker, g.battle!.defender].includes(owner), 'Spice Banker belongs to a current combatant.'); nexusRule(() => validateSpiceBankerSpend(battleLeaderSkills(g, p), leader?.id, plan.bankerSpice, p.spice, battleSupportCost(g, p, plan.support) - (plan.allyPayment ?? 0))); } if (!noFieldShipment && (g.leaderSkills || g.pendingShipment?.smuggler)) { const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment'); requireRule(decisions.length === (g.pendingShipment ? 1 : 0), 'The saved Leader Skills shipment lost its unique Guild interception decision.'); for (const decision of decisions) validateGuildShipmentDecision(g, decision); } if (!g.leaderSkills) return; requireRule(g.leaderSkills.assignments.every((assignment) => g.players.find((p) => p.id === assignment.owner)?.leaders.some((leader) => leader.id === assignment.leader && !leader.dead)), 'A saved Leader Skill cannot remain assigned to a dead or missing leader.'); skillRule(() => validateLeaderSkills(g.leaderSkills!, g.players)); requireRule( !g.players.some((p) => p.leaders.some((l) => l.gholaBy)), 'Leader Skills with foreign gholas await their custody integration.', ); if (g.battle?.leaderSkillHidden) requireRule( Object.entries(g.battle.leaderSkillHidden).every( ([id, hidden]) => [g.battle!.attacker, g.battle!.defender].includes(id) && typeof hidden === 'boolean', ), 'Invalid saved skilled-leader visibility.', ); if (g.decision?.kind === 'leaderSkillVisibility') requireRule( g.battle?.event === g.decision.event && g.leaderSkills.assignments.some((a) => a.owner === g.decision!.player), 'The saved skilled-leader choice lost its battle or assigned leader.', ); if (g.decision?.kind === 'leaderSkillRevival') requireRule( g.leaderSkills.offers[g.decision.player]?.event === g.decision.event, 'The saved revival choice lost its physical skill offer.', ); } function leaderSkillController( g: Game, assignment: { owner: string; leader: string }, ): string { return ( getPlayer(g, assignment.owner).leaders.find( (l) => l.id === assignment.leader, )!.capturedBy ?? assignment.owner ); } function battleLeaderSkills(g: Game, player: Player): BattleLeaderSkill[] { return ( g.leaderSkills?.assignments .filter((assignment) => leaderSkillController(g, assignment) === player.id) .map((assignment) => ({ skill: assignment.skill, leader: assignment.leader, faceUp: assignment.owner === player.id && !g.battle?.leaderSkillHidden?.[player.id], captured: assignment.owner !== player.id, })) ?? [] ); } function canUsePlanetologistWeapon( g: Game, player: Player, leader: string | null, card: Card | undefined, ) { return canUsePlanetologistBattleSpecial({ assignments: battleLeaderSkills(g, player), selectedLeader: leader, card, }); } function nativeLeaderSkill(g: Game, owner: string) { return g.leaderSkills?.assignments.find( (a) => a.owner === owner && leaderSkillController(g, a) === owner, ); } function nativeBureaucrat(g: Game) { return g.leaderSkills?.assignments.find(a => a.skill === 'bureaucrat' && getPlayer(g,a.owner).leaders.some(l => l.id === a.leader && !l.dead && !l.capturedBy && !l.gholaBy) && !g.battle?.leaderSkillHidden?.[a.owner]); } function bureaucratPaymentQuote(g: Game,source: Pick) { const owner = nativeBureaucrat(g)?.owner ?? null; return quoteBureaucratPayment({...source,owner,turn:g.turn,phase:g.phase,used:g.bureaucratPayments?.used ?? []}); } function checkBureaucratContributors(g: Game,payee:string|undefined,shares:{payer:string;amount:number}[],privateFunding=false) { if (!g.leaderSkills || !payee) return; const paid = shares.filter(share => share.amount > 0); const owner = nativeBureaucrat(g)?.owner; if (!owner || owner === payee || bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase) || paid.reduce((total,share) => total+share.amount,0) < 5 || !paid.some(share => share.payer !== owner && share.payer !== payee)) return; requireRule(bureaucratPaymentModeSupported(g),'Bureaucrat with other optional modules is still being integrated.'); requireRule(paid.length === 1 && !privateFunding,'Bureaucrat on allied funding or multiple payer contributions is awaiting private payment-split integration.'); } function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number): {bureaucratPayment?:BureaucratPaymentSource;bureaucratPaymentEvent?:string} { if (!g.leaderSkills || !bureaucratPaymentModeSupported(g) || amount < 5 || payer === payee) return {}; const source:BureaucratPaymentSource = {event:crypto.randomUUID(),turn:g.turn,phase:g.phase,kind,payer,payee,amount,signature:''}; source.signature = bureaucratPaymentSignature(source); return {bureaucratPayment:source,bureaucratPaymentEvent:source.event}; } function bureaucratPaymentFrame(g: Game): string { return JSON.stringify({turn:g.turn,phase:g.phase,active:g.active,order:g.order, players:g.players.map(p => ({id:p.id,spice:p.spice,bribes:p.bribes,hand:p.hand,leaders:p.leaders,forces:p.forces, elites:p.elites,reserves:p.reserves,tanks:p.tanks,ally:p.ally,shipped:p.shipped,moved:p.moved})), aid:g.aid,auction:g.auction,sale:g.currentAuctionSale,richeseAuction:g.richeseAuction,richeseBidding:g.richeseBidding, richeseFunding:g.richeseFunding,richeseCache:g.richeseCache,pendingIxAlly:g.pendingIxAlly, battle:g.battle,leaderSkills:g.leaderSkills,phaseOpening:g.phaseOpening}); } function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) { requireRule(source && typeof source === 'object' && typeof source.event === 'string' && source.event.length > 0 && source.turn === g.turn && source.phase === g.phase && ['auction','shipment','bribe','revival'].includes(source.kind) && (source.kind !== 'revival' || g.phase === 4) && g.players.some(p => p.id === source.payer) && g.players.some(p => p.id === source.payee) && source.payer !== source.payee && Number.isSafeInteger(source.amount) && source.amount >= 5 && source.signature === bureaucratPaymentSignature(source), 'The saved Bureaucrat payment changed its actual payer, payee, amount or event.'); } function bureaucratPaymentIntegrity(g: Game) { const state = g.bureaucratPayments,events = g.bureaucratUseEvents ?? []; requireRule(!state || Array.isArray(state.used),'The saved Bureaucrat usage ledger is invalid.'); requireRule(Array.isArray(events) && events.length === (state?.used.length ?? 0) && new Set(events).size === events.length && new Set((state?.used ?? []).map(use => use?.event)).size === (state?.used.length ?? 0), 'The physical Bureaucrat card lost its independent usage events.'); const phases = new Set(); for (const use of state?.used ?? []) { const phase = `${use.turn}:${use.phase}`; requireRule(use && typeof use.event === 'string' && events.includes(use.event) && !phases.has(phase) && Number.isSafeInteger(use.turn) && use.turn >= 1 && use.turn <= g.turn && Number.isSafeInteger(use.phase) && use.phase >= 0 && use.phase <= 8 && (use.turn < g.turn || use.phase <= g.phase) && g.players.some(p => p.id === use.owner) && use.signature === bureaucratPaymentSignature(use), 'The physical Bureaucrat skill cannot gain a second use in the same phase.'); phases.add(phase); } const pending = state?.pending; const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,continuation && 'resume' in continuation ? continuation.resume : null,pending?.resume]; const responses = contexts.flatMap(context => context?.response ? [context.response] : []); if (g.pendingKarama?.use.kind === 'cancel') responses.push(g.pendingKarama.use.response); for (const carrier of [g.currentAuctionSale,...responses].filter((value): value is NonNullable => !!value)) { const source = carrier.bureaucratPayment; if (!source) {requireRule(!carrier.bureaucratPaymentEvent,'The saved payment lost its original Bureaucrat source.');continue;} requireRule(g.leaderSkills && bureaucratPaymentModeSupported(g) && carrier.bureaucratPaymentEvent === source.event, 'The saved Bureaucrat payment lost its module or independent event.'); validateBureaucratSource(g,source); if ('winner' in carrier) requireRule(source.kind === 'auction' && !carrier.free && source.amount === carrier.amount && (source.payer === carrier.winner || getPlayer(g,carrier.winner).ally === source.payer) && source.payee === (carrier.origin !== 'normal' && carrier.seller !== carrier.winner ? carrier.seller : byFaction(g,'emperor')?.id), 'The Bureaucrat source no longer matches its paid auction sale.'); else requireRule(source.payee === carrier.owner && (carrier.kind === 'guildIncome' && source.kind === 'shipment' && source.amount === carrier.amount || carrier.kind === 'emperorIncome' && source.kind === 'auction' && g.currentAuctionSale?.bureaucratPaymentEvent === source.event || carrier.kind === 'revivalIncome' && source.kind === 'revival' && byFaction(g,'tleilaxu')?.id === source.payee && source.amount <= (carrier.amount ?? -1) && (carrier.amount ?? 0) <= source.amount + 1 && source.payer === carrier.recipient), 'The Bureaucrat source no longer matches its original native income.'); } const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'bureaucratPayment'); if (!pending) {requireRule(!g.bureaucratPaymentEvent && !decisions.length,'The saved Bureaucrat decision lost its original payment.');return;} validateBureaucratSource(g,pending.source); const assignment = nativeBureaucrat(g); requireRule(g.status === 'playing' && g.leaderSkills && bureaucratPaymentModeSupported(g) && !g.response && g.bureaucratPaymentEvent === pending.source.event && pending.signature === bureaucratPaymentSignature(pending) && pending.frame === bureaucratPaymentFrame(g) && assignment?.owner === pending.owner && assignment.leader === pending.leader && bureaucratPaymentQuote(g,pending.source) && decisions.length === 1 && decisions[0] === g.decision && decisions[0].player === pending.owner && decisions[0].event === pending.source.event, 'The pending Bureaucrat payment changed its trainer, resources, callback or unique decision.'); if (pending.continuation.kind === 'response') requireRule(pending.resume.response?.bureaucratPaymentEvent === pending.source.event, 'The Bureaucrat income lost its uncanceled response continuation.'); else if (pending.continuation.kind === 'auction') requireRule(g.currentAuctionSale?.bureaucratPaymentEvent === pending.source.event && JSON.stringify(pending.continuation.quote) === JSON.stringify(currentAuctionContinuationQuote(g,{kind:'sale',free:false})), 'The Bureaucrat sale lost its original seller-credit continuation.'); else requireRule(pending.continuation.kind === 'bribe' && pending.source.kind === 'bribe', 'The Bureaucrat payment has an invalid continuation.'); } function offerBureaucratPayment(g: Game,source:BureaucratPaymentSource|undefined, continuation:NonNullable['pending']>['continuation']): boolean { if (!source || !bureaucratPaymentQuote(g,source)) return false; requireRule(!g.bureaucratPayments?.pending,'Finish the current Bureaucrat payment first.'); validateBureaucratSource(g,source); const assignment = nativeBureaucrat(g)!; const pending = {source,owner:assignment.owner,leader:assignment.leader, resume:{response:g.response,decision:g.decision},continuation,frame:bureaucratPaymentFrame(g),signature:''}; pending.signature = bureaucratPaymentSignature(pending); (g.bureaucratPayments ??= {used:[]}).pending = pending; g.bureaucratPaymentEvent = source.event; g.response = null; g.decision = {kind:'bureaucratPayment',player:assignment.owner,event:source.event}; return true; } function projectedBureaucrat(g: Game,viewer: string): BureaucratPaymentView { const pending = g.bureaucratPayments?.pending; const privateRevival = pending?.source.kind === 'revival' && ![pending.owner,pending.source.payer,pending.source.payee,pending.resume.response?.recipient].includes(viewer); return {pending:pending && !privateRevival ? {event:pending.source.event,owner:pending.owner,payer:pending.source.payer,payee:pending.source.payee, amount:pending.source.amount,kind:pending.source.kind,redirect:2} : null, usedThisPhase:bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase)}; } function actBureaucratPayment(g: Game,action:Action) { const pending = g.bureaucratPayments!.pending!; requireRule(action.event === pending.source.event && typeof action.redirect === 'boolean' && Object.keys(action).every(key => ['type','event','redirect'].includes(key)), 'Choose whether to redirect this exact Bureaucrat payment.'); const redirected = action.redirect ? 2 : 0; if (redirected) { const use:BureaucratPaymentUse = {event:pending.source.event,owner:pending.owner,turn:g.turn,phase:g.phase,signature:''}; use.signature = bureaucratPaymentSignature(use); g.bureaucratPayments!.used.push(use);(g.bureaucratUseEvents ??= []).push(use.event); log(g,`${getPlayer(g,pending.owner).name} used Bureaucrat to redirect 2 spice of ${pending.source.kind === 'revival' ? 'a qualifying revival payment' : `the ${pending.source.amount}-spice payment`} to the Bank.`); } delete g.bureaucratPayments!.pending;delete g.bureaucratPaymentEvent; g.response = pending.resume.response;g.decision = pending.resume.decision; if (pending.continuation.kind === 'response') finishResponse(g,false,redirected); else if (pending.continuation.kind === 'auction') commitAuctionContinuation(g,pending.continuation.quote,redirected); else getPlayer(g,pending.source.payee).bribes += pending.source.amount-redirected; } function requireLeaderSkillRevival(g: Game, owner: string) { requireRule( !g.leaderSkills?.assignments.some( (a) => a.owner === owner && leaderSkillController(g, a) !== owner, ), 'Reviving another leader while your Leader Skill is captured awaits the replacement-skill ruling.', ); } function finishLeaderSkillCustody(g: Game, before?: Game) { if (!g.leaderSkills) return; const result = skillRule(() => returnDeadLeaderSkills(g.leaderSkills!, g.players, random), ); g.leaderSkills = result.state; for (const assignment of result.returned) { if (g.battle?.leaderSkillHidden) delete g.battle.leaderSkillHidden[assignment.owner]; log( g, `${leaderSkillCard(assignment.skill).name} returned to the shuffled skill deck because its assigned leader died.`, { faction: getPlayer(g, assignment.owner).faction, name: 'Leader Skill lost', }, ); } if (before?.leaderSkills) for (const player of g.players) for (const leader of player.leaders) if ( !leader.dead && !leader.capturedBy && !leader.gholaBy && !isAuditorLeader(leader) && before.players .find((p) => p.id === player.id) ?.leaders.find((l) => l.id === leader.id)?.dead ) { requireLeaderSkillRevival(g, player.id); g.leaderSkills = skillRule(() => offerRevivedLeaderSkill( g.leaderSkills!, player.id, leader.id, crypto.randomUUID(), ), ); } } function projectedLeaderSkills( g: Game, owner: string, ): LeaderSkillsView | null { if (!g.leaderSkills) return null; const offer = g.leaderSkills.offers[owner] ?? null; const eligible = skillEligibleLeaders(g, owner); const assignment = nativeLeaderSkill(g, owner); const unavailableSkills = Object.fromEntries( (offer?.cards ?? []).flatMap((skill) => { const reason = leaderSkillAssignmentUnavailable(g, owner, skill); return reason ? [[skill, reason]] : []; }), ) as Partial>; return { assignments: g.leaderSkills.assignments.map((a) => { const controller = leaderSkillController(g, a); return { ...a, controller, captured: controller !== a.owner, faceUp: controller === a.owner && !g.battle?.leaderSkillHidden?.[a.owner], }; }), offer, ...(Object.keys(unavailableSkills).length ? { unavailableSkills } : {}), eligibleLeaders: getPlayer(g, owner) .leaders.filter((l) => eligible.includes(l.id)) .map((l) => ({ id: l.id, name: l.name })), battleChoice: assignment && g.decision?.kind === 'leaderSkillVisibility' && g.decision.player === owner ? { event: g.decision.event, leader: assignment.leader, skill: assignment.skill, } : null, }; } /** Later Voice/card use can remove the last alternative to the skilled disc. */ function finishMandatorySkillVisibility(g: Game) { const b = g.battle; if (!g.leaderSkills || !b || b.revealed) return; for (const owner of [b.attacker, b.defender]) { if (b.plans[owner] || b.leaderSkillHidden?.[owner] !== false) continue; const assignment = nativeLeaderSkill(g, owner); if (!assignment) continue; const p = getPlayer(g, owner); const available = controlledLeaders(g, p).filter( (l) => !l.dead && (!l.usedAt || l.usedAt === b.territory), ); const heroAvailable = p.hand.some((c) => c.kind === 'hero') && !(b.voice?.target === owner && b.voice.kind === 'hero' && !b.voice.must); if ( available.length === 1 && available[0].id === assignment.leader && !heroAvailable ) { b.leaderSkillHidden![owner] = true; log( g, `${p.name} moved the skilled leader behind the shield because the remaining legal Battle Plan must use that leader.`, { faction: p.faction, name: 'Leader Skill' }, ); } } } function initializeSetup(g: Game) { const ecaz = byFaction(g, 'ecaz'); if (ecaz && g.advanced) g.ecazLoyalty = { player: ecaz.id, card: null }; if (g.discoveryEnabled) g.discoveries = createDiscoveryState(random); if (g.nexusCards) g.nexusCards = { cards: createNexusCards(g.players, random), phase: null }; const choam = byFaction(g, 'choam'); if (choam && g.advanced && !choam.leaders.some(isAuditorLeader)) choam.leaders.push(createAuditorLeader()); if (g.strongholdCards) { requireRule(g.advanced, 'Stronghold Cards require Advanced rules.'); g.strongholdCards = createStrongholdCards(); } if (g.techTokens) { requireRule( g.players.length >= 3, 'Tech tokens currently require at least three players.', ); g.techTokens = createTechTokens(g.players); } if (byFaction(g, 'richese')) { g.richeseCache = richeseCards(); g.richeseRemoved = []; } g.playerPositions = normalizedPlayerPositions(g); g.storm = STORM_START_SECTOR; stormOrder(g); g.stormDialers = [g.order[0], g.order.at(-1)!]; g.lastBattle = [...g.stormDialers]; g.deck = shuffle([ ...treacheryDeck(g.expansions), ...(g.ecazTreachery ? ecazTreacheryCards() : []), ]); g.spiceDeck = shuffle([...spiceDeck(g.expansions.includes('ix')), ...(g.discoveryEnabled ? [...DISCOVERY_SPICE_CARDS, {worm:true as const, greatMaker:true as const}] : [])]); g.sandtrout = false; g.status = 'setup'; g.setupStage = 'prediction'; g.ready = []; log( g, byFaction(g, 'beneGesserit') ? 'Player positions are fixed. Bene Gesserit must lock its prediction before private cards are dealt.' : g.leaderSkills ? 'Player positions are fixed. Starting Treachery Cards and Leader Skills precede traitor selection in this variant.' : 'Player positions are fixed. Private traitor selection comes before force placement and starting Treachery Cards.', ); } function initializeStartingForces(g: Game) { for (const p of g.players) { p.spice = faction(p.faction).spice; if (p.faction === 'richese') { p.noField = createRicheseNoField([ crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID(), ]); p.noFieldEvent = crypto.randomUUID(); } if (p.faction === 'moritani') g.moritaniTerror = createTerrorState(random); if (p.faction === 'ecaz') g.ecazAmbassadors = createAmbassadors(random); if (p.faction === 'moritani' || p.faction === 'ecaz') g.dukeVidal ??= createDukeVidal(); delete p.elites; if (p.faction === 'ixians') { p.elites = { reserves: 4, tanks: 0, forces: {}, revived: 0 }; place(p, MOBILE_STRONGHOLD, 0, 6, 3); p.reserves -= 6; g.mobileStronghold = { location: null }; } if ( (g.advanced || g.homeworlds) && ['emperor', 'fremen'].includes(p.faction) ) p.elites = { reserves: p.faction === 'emperor' ? 5 : 3, tanks: 0, forces: {}, revived: 0, }; const pos: Record = { atreides: ['arrakeen', 10, 10], harkonnen: ['carthag', 11, 10], guild: ['tueks_sietch', 5, 5], beneGesserit: ['polar_sink', 0, 1], }; const t = pos[p.faction]; if (t && !(g.advanced && p.faction === 'beneGesserit')) { place(p, ...t); p.reserves -= t[2]; } } if (g.homeworlds) g.homeworlds.custody = createHomeworldCustody(homeworldContext(g)); } function dealStartingTreachery(g: Game) { const ixians = byFaction(g, 'ixians'); if (ixians) { g.ixSetupCards = []; for (const _player of g.players) { const card = draw(g); if (card) g.ixSetupCards.push(card); } g.decision = { kind: 'ixSetup', player: ixians.id }; } else { for (const p of g.players) { const quantity = p.faction === 'harkonnen' ? 2 : 1; for (let i = 0; i < quantity; i++) { const card = draw(g); if (card) p.hand.push(card); } log( g, `${p.name} received ${quantity} starting Treachery Card${quantity === 1 ? '' : 's'} ${g.leaderSkills ? 'before Leader Skill assignment' : 'after force placement'}.`, { faction: p.faction, name: 'Starting cards' }, ); } } if (ixians) log( g, g.leaderSkills ? 'Ixians must choose their starting card before the remaining cards and Leader Skills are dealt.' : 'Starting force placement is complete. Ixians must choose their starting card before the remaining cards are dealt.', ); } function setupPending(g: Game): string[] { if (g.status !== 'setup' || !g.setupStage) return []; if (g.setupStage === 'prediction') { const bg = byFaction(g, 'beneGesserit'); return bg && !bg.prediction ? [bg.id] : []; } if (g.setupStage === 'skillTreachery') return g.ixSetupCards ? [byFaction(g, 'ixians')!.id] : []; if (g.setupStage === 'leaderSkills') return Object.keys(g.leaderSkills!.offers); if (g.setupStage === 'traitors') return g.players .filter((p) => p.traitorChoices.length > 0) .map((p) => p.id); const fremen = byFaction(g, 'fremen'); if (fremen?.reserves === 20) return [fremen.id]; const ecaz = byFaction(g, 'ecaz'); if (ecaz?.reserves === 20) return [ecaz.id]; const bg = byFaction(g, 'beneGesserit'); return g.advanced && bg && !bg.advisorSetup ? [bg.id] : []; } function advanceSetup(g: Game) { if (!g.setupStage || g.status !== 'setup') return; if (g.setupStage === 'prediction') { if (setupPending(g).length) return; if (g.leaderSkills) { g.setupStage = 'skillTreachery'; dealStartingTreachery(g); if (g.decision) return; } } if (g.setupStage === 'skillTreachery') { if (g.ixSetupCards || g.decision) return; g.leaderSkills = skillRule(() => dealLeaderSkills(g.leaderSkills!, g.players.map((p) => p.id), crypto.randomUUID())); g.setupStage = 'leaderSkills'; log(g, 'Each faction received two private Leader Skill cards. Keep one and assign an eligible leader before traitors are dealt.'); } if (g.setupStage === 'leaderSkills' && setupPending(g).length) return; if (g.setupStage === 'prediction' || g.setupStage === 'leaderSkills') { const inventory = traitorDeck(g.players, g.expansions.includes('ix')); if (g.ecazLoyalty && g.ecazLoyalty.card === null) { g.ecazLoyalty.card = chooseEcazLoyalty(inventory, random()); const loyal = leaders('ecaz').find(leader => leader.id === g.ecazLoyalty!.card)!; log(g, `Ecaz Loyalty set ${loyal.name}’s Traitor Card face up outside the deck before initial dealing. This card stays out of the game; its leader is unchanged.`, { faction: 'ecaz', name: 'Loyalty' }); } const traitors = shuffle(withoutEcazLoyalty(inventory, g.ecazLoyalty)); for (const p of g.players) { p.traitorChoices = p.faction === 'tleilaxu' ? [] : traitors.splice(0, 4); if (p.faction === 'harkonnen') { p.traitors = p.traitorChoices; p.traitorChoices = []; } } g.traitorReserve = traitors; g.setupStage = 'traitors'; log( g, g.leaderSkills ? 'Leader Skills are assigned publicly. Traitor Cards were dealt privately; choose before starting force placement.' : 'Traitor Cards were dealt privately. Choose traitors before starting force placement; Treachery Cards remain undealt.', ); } if (g.setupStage === 'traitors') { if (setupPending(g).length) return; initializeStartingForces(g); g.setupStage = 'forces'; log( g, g.leaderSkills ? 'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement to begin play.' : 'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement before receiving Treachery Cards.', ); } if (g.setupStage === 'forces' && !setupPending(g).length) { delete g.setupStage; if (!g.leaderSkills) dealStartingTreachery(g); } } /** Offline-only test seam. No player action or room API dispatches this function. */ export function initializeBaseGameForAudit(state: Game): Game { return initializeSetupGameForAudit(state, false); } /** Offline-only seam through the real setup pipeline. Public Homeworld starts remain gated. */ export function initializeHomeworldGameForAudit(state: Game): Game { requireRule( !!state.homeworlds, 'Enable the Homeworld module in the audit lobby first.', ); return initializeSetupGameForAudit(state, true); } /** Offline-only seam for the independent Nexus module; public starts remain gated. */ export function initializeNexusGameForAudit(state: Game): Game { requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null, 'Enable Nexus cards in a fresh audit lobby first.'); return initializeSetupGameForAudit(state, !!state.homeworlds, true); } /** Offline paired-faction Nexus setup; public expansion/module starts remain gated. */ export function initializePairedNexusGameForAudit(state: Game): Game { const expansion = state.expansions[0]; requireRule(state.expansions.length === 1 && ((expansion === 'choam' && state.players.some(player => player.faction === 'choam') && state.players.some(player => player.faction === 'richese')) || (expansion === 'ecaz' && state.players.some(player => player.faction === 'ecaz') && state.players.some(player => player.faction === 'moritani')) || (expansion === 'ix' && state.players.some(player => player.faction === 'ixians') && state.players.some(player => player.faction === 'tleilaxu'))), 'The paired Nexus sample requires both selected expansion factions and their deck.'); requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null && !state.homeworlds && !state.leaderSkills && !state.discoveryEnabled && !state.ecazTreachery, 'Enable only Nexus Cards in a fresh paired expansion lobby.'); return initializeSetupGameForAudit(state, false, true, false, false, false, false, true); } /** Offline-only all-expansion Nexus composition, optionally with Homeworlds; not a public start gate. */ export function initializeCombinedNexusGameForAudit(state: Game, homeworlds = false): Game { requireRule(state.expansions.length === 3 && ['ix', 'choam', 'ecaz'].every(expansion => state.expansions.includes(expansion)) && [5, 6].includes(state.players.length) && state.players.every(player => ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].includes(player.faction)) && ['ixians', 'tleilaxu', 'choam', 'ecaz', 'moritani'] .every(faction => state.players.some(player => player.faction === faction)), 'The combined Nexus sample requires five or six expansion factions and their three decks.'); requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null && !!state.homeworlds === homeworlds && !state.leaderSkills && !state.discoveryEnabled && !state.ecazTreachery && !state.techTokens && !state.strongholdCards, 'Enable only the selected Nexus and Homeworld modules in a fresh combined expansion lobby.'); return initializeSetupGameForAudit(state, homeworlds, true, false, false, false, false, true); } /** Offline prototype entry through real Ix setup, including both expansion factions. * No player action or room API bypasses the normal release gates. */ export function initializeIxGameForAudit(state: Game): Game { requireRule(state.expansions.length === 1 && state.expansions[0] === 'ix', 'The Ix prototype requires exactly the Ixians & Tleilaxu expansion.'); return initializeSetupGameForAudit(state, false, false, true); } /** Genuine faction-only development setup. Optional variants and public starts remain gated. */ export function initializeFactionExpansionsGameForAudit(state: Game): Game { requireRule(typeof state.advanced === 'boolean', 'Choose Basic or Advanced rules for the faction prototype.'); requireRule(Array.isArray(state.expansions) && state.expansions.length > 0 && state.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) && new Set(state.expansions).size === state.expansions.length, 'The faction prototype needs a nonempty selection of distinct known expansions.'); requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills && !state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker && !state.techTokens && !state.strongholdCards, 'The faction prototype excludes optional modules, including Leader Skills and Discoveries.'); requireRule(state.richeseCache === undefined && state.richeseRemoved === undefined && state.ecazAmbassadors === undefined && state.moritaniTerror === undefined && state.dukeVidal === undefined && state.mobileStronghold === undefined && state.ixSetupCards === undefined && state.players.every(p => { const original = leaders(p.faction); return p.noField === undefined && p.noFieldEvent === undefined && p.faceDancers === undefined && p.revived === 0 && p.freeForcesRevived === 0 && !p.leaderRevived && p.revivalCycle === 0 && p.battleLosses === 0 && p.ally === null && p.bribes === 0 && p.leaders.length === original.length && original.every(expected => { const actual = p.leaders.find(l => l.id === expected.id); return actual && Object.keys(actual).length === Object.keys(expected).length && Object.entries(expected).every(([key, value]) => actual[key as keyof Leader] === value); }); }), 'The faction initializer cannot overwrite existing inventories, leader custody or revival history.'); return initializeSetupGameForAudit(state, false, false, false, false, false, false, true); } /** Genuine, explicitly opted-in preview; no player action enables this profile. */ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game { requireRule(!state.moritaniAssassinatePreview && !state.moritaniAssassinate && !state.moritaniAssassinateResume && !state.moritaniAssassinateCallEvents, 'The assassination preview starts only from a fresh lobby.'); requireRule(moritaniAssassinateModeSupported(state), 'The assassination preview needs Advanced Moritani with ordinary base opponents except Harkonnen, the Ecaz expansion and no optional modules.'); const g = initializeFactionExpansionsGameForAudit(state); g.moritaniAssassinatePreview = true; g.moritaniAssassinate = {version:1,owner:byFaction(g,'moritani')!.id,normalTraitorCall:false,opportunities:[]}; g.moritaniAssassinateCallEvents = []; return g; } /** Development-only Semuta reaction in fresh Richese games, optionally with * the physical Ix Treachery deck; public expansion starts stay gated. */ export function initializeSemutaGameForAudit(state: Game): Game { requireRule(!state.semutaPreview && state.expansions.includes('choam') && state.expansions.every(id => id === 'choam' || id === 'ix') && state.players.some(p => p.faction === 'richese'), 'Semuta needs a fresh Richese faction game with no optional modules.'); const g = initializeFactionExpansionsGameForAudit(state); g.semutaPreview = true; return g; } /** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */ export function initializeDiscoveryGameForAudit(state: Game): Game { requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker, 'Enable Discoveries in a fresh audit lobby first.'); return initializeSetupGameForAudit(state, false, false, false, true); } /** Gated development setup; never dispatched by a player action or room route. */ export function initializeLeaderSkillsGameForAudit(state: Game): Game { requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.'); const g = structuredClone(state); g.leaderSkills = createLeaderSkills(random); return initializeSetupGameForAudit(g, false, false, false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam', basicExpansionLeaderSkillsProfile(g)); } /** Gated development setup for the independent three-card Ecaz variant. */ export function initializeEcazTreacheryGameForAudit(state: Game): Game { requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.'); const g = structuredClone(state); g.ecazTreachery = true; return initializeSetupGameForAudit(g, false, false, false, false, false, false, true, true); } function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false, ecazTreachery = false): Game { nexusCardsIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); homeworldSubstitutionIntegrity(state); homeworldDefenseIntegrity(state); homeworldShipmentIntegrity(state); const g = structuredClone(state); requireFreshSetup(g, homeworlds || nexus || ix || factions); requireRule( (homeworlds || nexus || ix || choam || factions || g.expansions.length === 0) && (choam || factions || !g.expansions.includes('choam')) && (factions || !g.expansions.includes('ecaz')) && (leaderSkills || !g.leaderSkills) && (discovery || !g.discoveryEnabled) && (nexus || !g.nexusCards) && (ecazTreachery || !g.ecazTreachery) && !g.techTokens && !g.strongholdCards && (homeworlds || !g.homeworlds) && g.players.every((p) => FACTIONS.some( (f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam') || (factions && g.expansions.includes(f.expansion))), ), ), factions && nexus ? 'The paired expansion Nexus audit needs a fresh lobby without other optional modules.' : factions ? 'The faction prototype supports base factions and the selected expansion factions without optional modules.' : choam ? 'The Leader Skills prototype supports base, CHOAM and Richese factions without other expansions or optional modules.' : ix ? 'The Ix prototype supports base, Ixian and Tleilaxu factions without optional modules.' : homeworlds ? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.' : 'The audit initializer supports base factions without expansions or optional modules.', ); initializeSetup(g); return normalizeAutomaticGame(g); } function ecazStartingForcesComplete(p: Player): boolean { if (p.reserves !== 20 - ECAZ_START_FORCES) return false; try { quoteEcazStartingForces(p.forces); return true; } catch { return false; } } function otherSetupComplete(g: Game) { return ( !g.setupStage && !g.ixSetupCards && g.players.every( (p) => (p.faction === 'tleilaxu' || p.traitors.length > 0) && (p.faction !== 'fremen' || p.reserves === 10) && (p.faction !== 'ecaz' || ecazStartingForcesComplete(p)) && (p.faction !== 'beneGesserit' || (p.prediction && (!g.advanced || p.advisorSetup))), ) ); } function setupComplete(g: Game) { return ( otherSetupComplete(g) && !g.players.some((p) => p.faction === 'moritani' && p.reserves === 20) ); } function extortionEvent(state: ExtortionState): string { return JSON.stringify(['moritaniExtortion', state.turn, state.token, state.cursor]); } function extortionContext(g: Game) { return { turn: g.turn, phase: g.phase, players: g.players.map(player => ({ id: player.id, spice: player.spice })) }; } function extortionRule(apply: () => T): T { try { return apply(); } catch (error) { if (error instanceof Error) throw new RuleError(error.message); throw error; } } function moritaniExtortionIntegrity(g: Game) { const pending = g.moritaniExtortion; const revealed = g.moritaniTerror?.tokens.filter(token => token.status === 'extortion') ?? []; requireRule(revealed.length === (pending ? 1 : 0) && (!pending || (revealed[0].id === pending.token && revealed[0].kind === 'extortion' && byFaction(g, 'moritani')?.id === pending.owner && g.status === 'playing')), 'Extortion has lost its revealed token or Moritani owner.'); if (!pending) return; extortionRule(() => validateExtortionState(pending, extortionContext(g))); const stormPayers = pending.stage === 'payment' ? g.order.filter(id => id !== pending.owner) : null; const savedDecisions = pending.stage === 'payment' ? homeworldSavedDecisions(g).filter(decision => decision.kind === 'moritaniExtortion') : []; requireRule(pending.stage === 'reserved' ? g.phase <= 8 : pending.stage === 'payment' && g.phase === 8 && g.moritaniTerror?.placementTurn === g.turn && savedDecisions.length === 1 && savedDecisions[0].player === pending.queue[pending.cursor] && savedDecisions[0].event === extortionEvent(pending) && pending.queue.length === stormPayers?.length && pending.queue.every((seat, index) => seat === stormPayers?.[index]), 'Extortion payment must resume in the original Mentat storm order.'); } function settleMoritaniExtortion(g: Game, state: ExtortionState, recover: boolean) { requireRule(g.moritaniTerror && state.stage === 'settled', 'Extortion must settle with its revealed Terror token.'); g.moritaniTerror = recover ? returnTerror(g.moritaniTerror, state.token, random) : retireExtortion(g.moritaniTerror, state.token); delete g.moritaniExtortion; log(g, recover ? 'No one paid Extortion. Its token returned to the hidden Terror supply.' : 'Extortion was paid. Its revealed Terror token was removed from the game.'); finishMoritaniPlacement(g); } function finishMoritaniPlacement(g: Game) { const obligation = g.moritaniExtortion; if (obligation?.stage === 'reserved') { requireRule(g.phase === 8 && g.moritaniTerror?.placementTurn === g.turn, 'Finish the Terror placement before collecting Extortion.'); const result = extortionRule(() => collectExtortion(obligation, { ...extortionContext(g), stormOrder: g.order, })); getPlayer(g, obligation.owner).spice += 5; g.moritaniExtortion = result.state; log(g, 'Moritani collected five reserved spice from the bank for Extortion.'); if (result.state.stage === 'payment') { const payer = result.state.queue[result.state.cursor]; g.decision = { kind: 'moritaniExtortion', player: payer, event: extortionEvent(result.state) }; } else settleMoritaniExtortion(g, result.state, true); return; } requireRule(!obligation, 'Finish the pending Extortion payments first.'); if (!byFaction(g, 'choam')) victory(g); } function finishSetup(g: Game) { const tleilaxu = byFaction(g, 'tleilaxu'); if (tleilaxu) { const held = new Set(g.players.flatMap((player) => player.traitors)); g.traitorReserve = shuffle( withoutEcazLoyalty(traitorDeck(g.players, g.expansions.includes('ix')), g.ecazLoyalty).filter( (card) => !held.has(card), ), ); tleilaxu.faceDancers = g.traitorReserve .splice(0, 3) .map((leader) => ({ leader, revealed: false })); } g.status = 'playing'; if (g.homeworlds?.custody) { g.homeworldOccupationHistory = homeworldRule(() => createHomeworldOccupationHistory(homeworldContext(g), g.homeworlds!.custody!, g.turn, crypto.randomUUID())); g.homeworlds.historyVersion = 1; const choam = byFaction(g, 'choam'); if (choam) g.tupileIntelligence = createTupileIntelligenceState(choam.id); } log(g, 'Setup complete. The first storm awaits two secret dials.'); openPhase(g, false); } /** Pure legacy/default projection. Existing occupied circles never move on join. */ function normalizedPlayerPositions(g: Game): Record { const positions: Record = {}; const occupied = new Set(); for (const p of g.players) { const position = g.playerPositions?.[p.id]; if (position === undefined) continue; requireRule( Number.isInteger(position) && position >= 1 && position <= 6 && !occupied.has(position), 'Player circles must be distinct positions from 1 to 6.', ); positions[p.id] = position; occupied.add(position); } for (const p of g.players) { if (positions[p.id] !== undefined) continue; const position = [1, 2, 3, 4, 5, 6].find( (candidate) => !occupied.has(candidate), ); requireRule(position !== undefined, 'There are only six player circles.'); positions[p.id] = position; occupied.add(position); } return positions; } function stormOrder(g: Game) { g.playerPositions = normalizedPlayerPositions(g); // A marker already under the storm has been reached; the next occupied // marker ahead counterclockwise goes first. Empty circles are skipped. const distance = (id: string) => (PLAYER_CIRCLE_SECTORS[g.playerPositions![id] - 1] - g.storm + 18) % 18 || 18; g.order = g.players .map((p) => p.id) .sort((a, b) => distance(a) - distance(b)); } function stormExposed(g: Game, key: string) { return stormExposesTerritory(splitLocation(key).territory, g.shieldWallDestroyed); } function moveStorm(g: Game, n: number) { g.stormResolution = { from: g.storm, distance: n, traversed: 0, pending: [], ...(g.ecologicalStorm?.stage === 'traversal' ? {discoveryStorm:g.ecologicalStorm.signature} : {}) }; offerChoamStorm(g); } function startStormMovement(g: Game, n: number) { const offer = nexusRule(() => discoveryStormOffer(g)); if (offer && !offer.blocked) { g.ecologicalStorm = nexusRule(() => createDiscoveryStorm(g,crypto.randomUUID())); g.decision = {kind:'ecologicalStorm',player:offer.owner,event:g.ecologicalStorm.event}; } else moveStorm(g,n); } function choamStormOptions(g: Game) { return choamStormTerritories(g); } function currentChoamStormQuote(g: Game) { try { return quoteChoamStormOffer(g); } catch (error) { if (error instanceof ChoamStormQuoteError) throw new RuleError(error.message); throw error; } } function commitChoamStormOffer(g: Game, quote: ChoamStormQuote) { if (quote.kind === 'decision') g.decision = quote.decision; else beginStormProtection(g); } function offerChoamStorm(g: Game) { commitChoamStormOffer(g, currentChoamStormQuote(g)); } function beginStormProtection(g: Game) { const n = g.stormResolution!.distance; const fremen = byFaction(g, 'fremen'); const crossed = new Set( Array.from({ length: n }, (_, i) => ((g.storm + i) % 18) + 1), ); if ( g.advanced && fremen && Object.keys(fremen.forces).some( (key) => stormExposed(g, key) && crossed.has(splitLocation(key).sector), ) ) { g.response = { kind: 'stormProtection', owner: fremen.id, passed: [], resume: 'storm', }; } else continueStorm(g); } function stormCasualties( g: Game, p: Player, key: string, count: number, elite: number, resume: 'storm' | 'shipment', ) { const amount = Math.ceil(count / 2), minElite = Math.max(0, amount - (count - elite)), maxElite = Math.min(amount, elite); if (minElite < maxElite) g.decision = { kind: 'stormLosses', player: p.id, key, amount, minElite, maxElite, resume, }; else kill(g, p, key, amount, false, minElite); } function continueStorm(g: Game) { disasterPreflight(() => validateStormTraversal(g)); normalizedPlayerPositions(g); const resolution = g.stormResolution!; const fremen = byFaction(g, 'fremen'); while (true) { while (resolution.pending.length) { const key = resolution.pending.shift()!; if (fremen && (fremen.forces[key] ?? 0) > 0) { stormCasualties( g, fremen, key, fremen.forces[key], fremen.elites?.forces[key] ?? 0, 'storm', ); if (g.decision) return; } } if (resolution.traversed === resolution.distance) break; resolution.traversed++; const s = stormSectorAfter(resolution.from, resolution.traversed); if (g.shieldWallDestroyed) for (const city of ['arrakeen', 'carthag']) if (territory(city).sectors.includes(s)) returnAmbassadorsIn(g, city, 'the storm crossed the exposed city'); for (const p of g.players) { const marker = p.noField?.deployed?.location; if (marker && marker.sector === s) { const t = territory(marker.territory); if ( stormExposesTerritory(t.id, g.shieldWallDestroyed) ) revealPlayerNoField(g, p, 'storm'); } for (const key of Object.keys(p.forces)) { const loc = splitLocation(key), t = territory(loc.territory); const exposed = stormExposesTerritory(t.id, g.shieldWallDestroyed); if (loc.sector === s && exposed) { if ( p.faction === 'choam' && resolution.choamProtected?.includes(t.id) ) continue; if ( g.advanced && p.faction === 'fremen' && resolution.protected !== false ) resolution.pending.push(key); else kill(g, p, key, p.forces[key]); } } } for (const key of Object.keys(g.spice)) if (splitLocation(key).sector === s) delete g.spice[key]; } if (g.ecologicalStorm?.stage === 'traversal') g.ecologicalStorm = nexusRule(() => finishDiscoveryStorm(g.ecologicalStorm!)); delete g.stormMovementSource; g.storm = stormSectorAfter(resolution.from, resolution.distance); stormOrder(g); if (g.turn === 1) assignRemainingTech(g); log(g, `Storm moved ${resolution.distance} sectors to sector ${g.storm}.`); g.stormDials = {}; g.stormPending = null; g.stormResolution = null; const ixians = byFaction(g, 'ixians'); if (ixians && g.mobileStronghold && !g.mobileStronghold.location) { g.decision = { kind: 'mobileStronghold', player: ixians.id, placement: true, }; return; } finishStorm(g); } function finishStorm(g: Game) { if (!openChoamMarket(g, 'storm')) advanceAfterStorm(g); } function advanceAfterStorm(g: Game) { const fremen = byFaction(g, 'fremen'); g.phase = 1; g.ready = []; if (g.advanced && fremen) { g.stormCard = shuffle([1, 2, 3, 4, 5, 6])[0]; g.stormCardKnown = false; g.response = { kind: 'stormPeek', owner: fremen.id, passed: [] }; } openPhase(g, false); } function refillSpice(g: Game) { if (!g.spiceDeck.length) { g.spiceDeck = shuffle( g.spiceDiscard .flat() .filter((c) => !('worm' in c && c.thumper)) .map((c) => ('worm' in c ? { worm: true as const, ...(c.greatMaker ? {greatMaker: true as const} : {}) } : c)), ); g.spiceDiscard = [[], []]; } } function drawSpice(g: Game) { refillSpice(g); return g.spiceDeck.shift(); } function devour( g: Game, t: string, protectedAlly?: string, protectFremen = true, ) { disasterPreflight(() => validateWormDevouring(g, t, protectedAlly, protectFremen), ); for (const p of g.players) if (wormConsumesForces(p, protectedAlly, protectFremen)) killTerritory(g, p, t, Infinity, false, 'worm'); for (const k of Object.keys(g.spice)) if (splitLocation(k).territory === t) delete g.spice[k]; const fremen = byFaction(g, 'fremen'); if (fremen && fremen.fremenNexusWormBlockedTurn !== g.turn && at(fremen, t)) g.wormRides.push(t); log(g, `Shai-Hulud appeared in ${territory(t).name}.`); } function beginWorm(g: Game, t: string, origin: 'natural' | 'additional' | 'summoned' = 'natural') { const fremen = byFaction(g, 'fremen'); // An empty appearance offers the same decision with or without the private // Fremen card; the offer itself must not disclose its custody. if ((origin !== 'summoned' || (g.summonedWorm?.event && !g.nexusFremenCunningOffer && !g.nexusFremenCunningRides?.some(row => row.stage === 'pending' || row.stage === 'select'))) && !g.greatMaker && g.nexusCards?.cards && !g.expansions.length && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.techTokens && !g.strongholdCards && g.players.every(seat => ['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit', 'fremen'].includes(seat.faction)) && fremen && !fremen.ally && g.phase === 1 && fremen.fremenNexusWormBlockedTurn !== g.turn && Object.entries(fremen.forces).some(([key, amount]) => { if (amount <= 0) return false; const source = splitLocation(key); return source.sector !== g.storm && TERRITORIES.some(row => row.id === source.territory && row.type === 'sand'); })) { const occurrence = captureFremenCunningOccurrence(g, { event: crypto.randomUUID(), territory: t, origin, ...(origin === 'summoned' ? { parent: g.summonedWorm!.event } : { pile: g.spiceSequence?.pile }), }); if (occurrence.initiallyEmpty) { requireRule(!g.nexusFremenCunningOffer, 'Finish the previous worm appearance before another Cunning offer.'); g.nexusFremenCunningOffer = { occurrence, owner: fremen.id }; g.decision = { kind: 'nexusFremenCunningOffer', player: fremen.id, event: occurrence.event }; return; } } if (fremen?.ally && at(getPlayer(g, fremen.ally), t)) { g.decision = { kind: 'wormProtection', player: fremen.id, territory: t, ally: fremen.ally, }; } else wormSurvival(g, t); } function currentFremenCunningResponse(g: Game, response: ResponseWindow) { const ride = g.nexusFremenCunningRides?.find(row => row.stage === 'pending' && row.occurrence.event === response.intent); requireRule(response.kind === 'nexusFremenCunning' && ride && response.owner === ride.authorization.owner && response.location === ride.occurrence.territory && ride.occurrence.turn === g.turn && g.phase === 1 && g.nexusCards?.cards?.discard.includes('fremen'), 'The Fremen Nexus interruption has lost its spent card or worm appearance.'); return ride; } function finishFremenCunningResponse(g: Game, response: ResponseWindow, canceled: boolean) { const ride = currentFremenCunningResponse(g, response); ride.stage = canceled ? 'complete' : 'queued'; log(g, canceled ? 'Karama prevented the Fremen Nexus remote ride; the original worm still resolves.' : 'Fremen Nexus Cunning will ride from one desert territory after this Nexus.', { faction: 'fremen', name: 'Fremen Nexus Cunning' }); wormSurvival(g, ride.occurrence.territory); } function wormSurvival(g: Game, t: string, protectedAlly?: string) { const fremen = byFaction(g, 'fremen'); if (fremen && at(fremen, t)) { g.response = { kind: 'wormSurvival', owner: fremen.id, location: t, recipient: protectedAlly, passed: [], }; } else { devour(g, t, protectedAlly); afterWorm(g); } } function afterWorm(g: Game) { if (g.greatMaker?.stage === 'worm') { g.wormRides = [...g.greatMaker.ridesBefore]; openGreatMakerVote(g); return; } const pending = g.summonedWorm; if (!pending) { continueSpice(g); return; } const rides = g.wormRides; Object.assign(g, pending.resume); g.summonedWorm = null; g.wormRides = [...g.wormRides, ...rides]; markNexusOccurred(g); g.ready = []; if (g.spiceWindow?.territory === pending.territory) { g.spiceWindow.harvested = true; g.spiceWindow.harvesterClosed = true; } const beforeBlow = !g.spiceSequence && !g.spiceResolution && !g.spiceWindow && !pending.resume.nexus; if (beforeBlow) { g.summonedBeforeBlow = true; g.nexus = false; } else g.nexus = true; // A summon during an already active ride returns that unmade ride to the // queue and opens negotiations before it. An intrusion already caused by a // completed ride resolves before opening the newly triggered Nexus. if (g.decision?.kind === 'wormRide') { g.wormRides.unshift(g.decision.territory); g.decision = null; } else if (g.decision?.kind === 'nexusFremenCunningRide') { const event = g.decision.event; const ride = g.nexusFremenCunningRides?.find(row => row.stage === 'select' && row.occurrence.event === event); requireRule(ride, 'The summoned worm has lost its interrupted Fremen Cunning ride.'); ride.stage = 'queued'; g.decision = null; } else if ( g.pendingAmbassador?.resume === 'wormRide' || g.pendingTerrorEntry?.resume === 'wormRide' || g.pendingArrivalOverlap?.resume === 'wormRide' || (g.pendingKarama?.use.kind === 'cancel' && g.pendingKarama.use.response.kind === 'advisorFlip' && g.pendingKarama.use.response.advisorResume === 'wormRide') || (g.decision?.kind === 'intrusion' && g.decision.wormRide) || (g.response?.kind === 'advisorFlip' && g.response.advisorResume === 'wormRide') ) g.summonedNexusBeforeRides = true; log( g, 'The summoned worm has resolved. Its Nexus occurs after the spice blow.', ); } function discoveryIntegrity(g: Game) { ecologicalStormIntegrity(g); discoveryEntryIntegrity(g); if (!g.discoveryEnabled) { requireRule(!g.discoveries && !g.discoveryStash, 'Discovery custody requires its enabled module.'); return; } if (g.status === 'lobby') { requireRule(!g.discoveries && !g.discoveryStash, 'Discovery components are dealt during setup.'); return; } requireRule(g.discoveries, 'The Discovery module has lost its physical tokens.'); nexusRule(() => validateDiscoveryState(g.discoveries!)); for (const token of g.discoveries.tokens) requireRule( (token.revealedTurn === null || token.revealedTurn <= g.turn) && (token.acquiredTurn === null || token.acquiredTurn <= g.turn) && (token.owner === null || g.players.some(player => player.id === token.owner)), 'A Discovery token has lost its receiving player or original turn.'); const record = g.discoveryStash, continuation = g.pendingTreacheryDiscard?.continuation; const pending = continuation?.kind === 'discoveryStash' ? continuation : null; const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; if (!record) { requireRule(!pending && !controls.some(control => control?.decision?.kind === 'discoveryDiscard'), 'The Discovery discard has lost its original draw.'); return; } const p = g.players.find(player => player.id === record.owner); requireRule(p && Object.keys(record).sort().join(',') === 'card,discarded,event,hand,owner,signature,stage,token,turn' && typeof record.event === 'string' && record.event && Number.isSafeInteger(record.turn) && record.turn >= 1 && record.turn <= g.turn && ['choose','discard','complete'].includes(record.stage) && Array.isArray(record.hand) && record.hand.every(id => typeof id === 'string') && new Set(record.hand).size === record.hand.length && record.hand.includes(record.card) && g.discoveries.tokens.some(token => token.id === record.token && token.face === 'treachery-card-stash' && token.status === 'removed' && token.revealedTurn === record.turn) && record.signature === discoveryStashSignature(record) && (record.stage === 'choose' ? record.discarded === null : record.discarded === null || record.hand.includes(record.discarded)) && (record.stage === 'discard') === !!pending, 'The Treachery Card Stash has lost its original draw or selected discard.'); const overflow = record.hand.length === handLimit(p) + 1; requireRule(record.hand.length >= 1 && record.hand.length <= handLimit(p) + 1 && (record.stage !== 'complete' ? overflow : overflow === (record.discarded !== null)), 'The Treachery Card Stash must complete its original overflow discard.'); if (record.stage === 'complete') { requireRule(!controls.some(control => control?.decision?.kind === 'discoveryDiscard'), 'The completed stash cannot retain a discard choice.'); return; } requireRule(g.status === 'playing' && g.turn === record.turn && g.phase === 7 && JSON.stringify(p.hand.map(card => card.id).sort()) === JSON.stringify(record.hand.filter(id => id !== record.discarded).sort()), 'Finish the original Treachery Card Stash hand before another card change.'); if (record.stage === 'choose') requireRule(controls.some(control => control?.decision?.kind === 'discoveryDiscard' && control.decision.player === p.id && control.decision.event === record.event), 'The stash has lost its owned discard choice.'); if (pending) requireRule(pending.event === record.event && pending.owner === p.id && pending.card === record.discarded, 'The stash has changed its committed discard.'); } function ecologicalStormIntegrity(g: Game) { const frame = g.ecologicalStorm, continuation = g.pendingTreacheryDiscard?.continuation; if (g.stormMovementSource) { nexusRule(() => validateStormSource(g.stormMovementSource!)); const source = g.stormMovementSource; requireRule(g.status === 'playing' && g.phase === 0 && source.turn === g.turn && g.stormPending === (frame?.stage === 'traversal' ? source.distance+frame.delta! : source.distance), 'The storm must retain its current turn and original movement source.'); } const controls = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,continuation && 'resume' in continuation ? continuation.resume : null]; const choices = controls.flatMap(c => c?.decision?.kind === 'ecologicalStorm' ? [c.decision] : []); if (!frame) { requireRule(!choices.length && !g.stormResolution?.discoveryStorm,'The station storm has lost its original choice.'); return; } nexusRule(() => validateDiscoveryStorm(g,frame)); if (frame.stage === 'choose') requireRule(choices.length === 1 && choices[0].player === frame.owner && choices[0].event === frame.event && !g.stormResolution && g.ready.length === g.players.length, 'The station has lost its owned choice before storm movement.'); else { requireRule(!choices.length,'A completed station choice cannot remain open.'); if (frame.stage === 'traversal') requireRule(g.stormResolution && g.stormResolution.discoveryStorm === frame.signature && g.stormResolution.from === frame.from && g.stormResolution.distance === frame.base+frame.delta!, 'The storm traversal has lost its exact station adjustment.'); else requireRule(!g.stormResolution?.discoveryStorm,'The completed station choice cannot retain a traversal.'); } } function discoveryEntryIntegrity(g: Game) { const frame = g.discoveryEntry; const continuation = g.pendingTreacheryDiscard?.continuation; const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const choices = controls.flatMap(control => control?.decision?.kind === 'discoveryEntry' ? [control.decision] : []); const children = controls.flatMap(control => { const pending = control && 'pendingKarama' in control ? control.pendingKarama as Game['pendingKarama'] : null; return [control?.response, pending?.use.kind === 'cancel' ? pending.use.response : null] .filter(response => response?.advisorResume === 'discoveryEntry' || response?.discoveryEntry) .map(response => ({receipt:response!.discoveryEntry, actor:response!.owner, territory:response!.location, valid:response!.kind === 'advisorFlip' && response!.advisorResume === 'discoveryEntry' && response!.advisors === true && response!.advisorAmbassadorEvent === undefined && response!.advisorFollowup === undefined && response!.advisorRemaining === undefined})); }); for (const control of controls) if (control?.decision?.kind === 'intrusion' && control.decision.discoveryEntry) children.push({receipt:control.decision.discoveryEntry, actor:control.decision.player, territory:control.decision.territory, valid:control.decision.wormRide === undefined && control.decision.ambassadorEvent === undefined && control.decision.followup === undefined}); if (!frame) { requireRule(!choices.length && !children.length, 'The Discovery entry has lost its original turn-opening round.'); return; } requireRule(g.discoveryEnabled && g.discoveries && g.status === 'playing' && g.phase === 0 && frame.stage !== 'complete', 'Discovery entry must finish before the Storm Phase begins.'); nexusRule(() => validateDiscoveryEntryRound({...g, discoveries:g.discoveries!},frame)); if (frame.stage === 'choose') { const owner = frame.order[frame.cursor % frame.order.length]; requireRule(!children.length && choices.length === 1 && choices[0].player === owner && choices[0].event === `${frame.event}:${frame.cursor}`, 'The Discovery entry has lost its owned choice.'); } else { requireRule(!choices.length && children.length === 1, 'The Discovery entry has lost its pending arrival interaction.'); const arrival = frame.arrival!, owner = getPlayer(g, arrival.owner), key = `${arrival.destination}:0`; requireRule(owner.forces[key] === arrival.destinationBefore + arrival.amount && (owner.elites?.forces[key] ?? 0) === arrival.destinationEliteBefore + arrival.elite, 'The Discovery arrival has lost its transferred ordinary or elite force custody.'); for (const child of children) { requireRule(child.valid && child.actor === byFaction(g,'beneGesserit')?.id && child.territory === frame.arrival!.destination && child.receipt, 'The Discovery arrival must retain its original Bene Gesserit interaction.'); nexusRule(() => validateDiscoveryEntryArrivalChild(frame, child.receipt!)); } } } function continueDiscoveryEntry(g: Game) { requireRule(g.discoveryEntry && g.discoveries, 'No Discovery entry round is pending.'); while (g.discoveryEntry!.stage === 'choose') { const frame: DiscoveryEntryRound = g.discoveryEntry!, owner = frame.order[frame.cursor % frame.order.length]; const offer = nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},frame,owner)); if (!offer.blocked) { g.decision = {kind:'discoveryEntry',player:owner,event:`${frame.event}:${frame.cursor}`}; return; } g.discoveryEntry = nexusRule(() => advanceDiscoveryEntry({...g,discoveries:g.discoveries!},frame,owner)); } requireRule(g.discoveryEntry!.stage === 'complete', 'Finish the Discovery arrival before the next entry.'); g.discoveries = {...g.discoveries,newlyRevealed:[]}; delete g.discoveryEntry; beginTurnBoardMovement(g); } function completeDiscoveryEntryArrival(g: Game, child: DiscoveryEntryArrivalChild) { requireRule(g.discoveryEntry && g.discoveries, 'The Discovery arrival has lost its round.'); nexusRule(() => validateDiscoveryEntryArrivalChild(g.discoveryEntry!,child)); g.discoveryEntry = nexusRule(() => finishDiscoveryEntryArrival({...g,discoveries:g.discoveries!},g.discoveryEntry!)); continueDiscoveryEntry(g); } function decideDiscoveryEntry(g: Game, p: Player, action: Action) { const frame = g.discoveryEntry; requireRule(frame && g.discoveries && typeof action.accept === 'boolean' && action.event === `${frame.event}:${frame.cursor}` && Object.keys(action).every(key => ['type','event','accept','groups'].includes(key)), 'Choose the current Discovery entry and explicit physical force groups.'); if (!action.accept) { requireRule(action.groups === undefined, 'Declining Discovery entry must not select forces.'); g.discoveryEntry = nexusRule(() => advanceDiscoveryEntry({...g,discoveries:g.discoveries!},frame,p.id)); log(g, `${p.name} declined this optional free Discovery entry.`); continueDiscoveryEntry(g); return; } g.discoveryEntry = nexusRule(() => beginDiscoveryEntryArrival({...g,discoveries:g.discoveries!},frame,p.id, {groups:action.groups as DiscoveryEntrySelection['groups']})); const arrival = g.discoveryEntry.arrival!; const advisors = arrivalAsAdvisor(g,p,arrival.destination,arrival.parent); removeGroup(p,arrival.groups.map(group => [group.source,group.normal+group.elite]), Object.fromEntries(arrival.groups.map(group => [group.source,group.elite]))); place(p,arrival.destination,0,arrival.amount,arrival.elite); if (advisors) (p.advisors ??= {})[arrival.destination] = {}; else if (p.advisors) delete p.advisors[arrival.destination]; log(g, `${p.name} moved ${arrival.amount} ${arrival.amount === 1 ? 'force' : 'forces'}${arrival.elite ? `, including ${arrival.elite} elite ${arrival.elite === 1 ? 'force' : 'forces'},` : ''} from ${territory(arrival.parent).name} into ${territory(arrival.destination).name} before the storm. This one-time Discovery entry spends no spice or normal movement.`); const child: DiscoveryEntryArrivalChild = {owner:p.id,territory:arrival.destination,sector:0, amount:arrival.amount,elite:arrival.elite,discoveryEntry:arrival.signature}; if (!intrusion(g,p,arrival.destination,{discoveryEntry:child})) completeDiscoveryEntryArrival(g,child); } function resolveDiscoveryBlow(g: Game, card: Extract) { requireRule(g.discoveryEnabled && g.discoveries && isDiscoverySpiceCardId(card.discovery), 'This Discovery card requires its physical module.'); const printed = DISCOVERY_SPICE_CARDS.find(value => value.discovery === card.discovery)!; requireRule(card.territory === printed.territory && card.sector === printed.sector && card.amount === printed.amount, 'The Discovery spice blow has changed its printed territory, sector or amount.'); disasterPreflight(() => validateWormDevouring(g,card.territory,undefined,false)); for (const p of g.players) killTerritory(g,p,card.territory); for (const key of Object.keys(g.spice)) if (splitLocation(key).territory === card.territory) delete g.spice[key]; log(g, `A Discovery spice blow destroyed all previous spice and forces in ${territory(card.territory).name}, including Fremen forces. The new spice is placed afterward.`); const placement = DISCOVERY_CARD_PLACEMENTS[card.discovery]; if (g.discoveries.tokens.some(token => token.type === placement.type && token.status === 'supply')) { g.discoveries = nexusRule(() => placeDiscovery(g.discoveries!,card.discovery as Parameters[1],random)); log(g, `A face-down ${placement.type === 'hiereg' ? 'Hiereg' : 'Smuggler'} Discovery was placed in ${territory(placement.territory).name}, sector ${placement.sector}.`); } else log(g, `The ${placement.type} Discovery supply is empty; no additional physical token can be placed.`); } function playDiscovery(g: Game, p: Player, action: Action) { const offer = discoveryChoices(g,p.id,g.phase === 7 && grummanCollectionAutomatic(g)); requireRule(offer && !offer.blocked && typeof action.reveal === 'boolean' && isDiscoveryTokenId(action.token) && Object.keys(action).every(key => ['type','token','reveal'].includes(key)) && (action.reveal ? offer.canReveal : offer.canInspect).includes(action.token), offer?.blocked ?? 'Choose an eligible hidden Discovery in a territory containing your non-advisor forces.'); if (!action.reveal) { g.discoveries = nexusRule(() => rememberDiscoveryFace(g.discoveries!,action.token as Parameters[1],p.faction)); return; } const token = g.discoveries!.tokens.find(token => token.id === action.token)!; if (token.face === 'spice-stash') requireRule(Number.isSafeInteger(p.spice) && p.spice >= 0 && Number.isSafeInteger(p.spice+7), 'The stash needs a valid spice balance.'); if (token.face === 'treachery-card-stash') requireRule(g.deck.length + g.discard.length > 0, 'No Treachery card is available to draw.'); const revealed = nexusRule(() => revealDiscoveryToken(g.discoveries!,token.id,g.turn,p.id)); g.discoveries = revealed.state; g.ready = []; log(g, `${p.name} revealed ${DISCOVERY_TOKEN_BY_ID[token.face].name} in ${territory(token.territory!).name}.`); if (revealed.outcome.kind === 'spice-stash') { p.spice += revealed.outcome.amount; log(g, `${p.name} gained 7 spice from the bank. The Spice Stash was removed from the game.`); } else if (revealed.outcome.kind === 'treachery-card-stash') { const card = draw(g)!; p.hand.push(card); const overflow = p.hand.length > handLimit(p); g.discoveryStash = {event:crypto.randomUUID(), token:token.id, owner:p.id, turn:g.turn, card:card.id, hand:p.hand.map(card => card.id), stage:overflow ? 'choose' : 'complete', discarded:null, signature:''}; g.discoveryStash.signature = discoveryStashSignature(g.discoveryStash); if (overflow) g.decision = {kind:'discoveryDiscard',player:p.id,event:g.discoveryStash.event}; log(g, `${p.name} drew one private Treachery card from the stash${overflow ? ' and must now discard any one card from the full hand' : ''}. The stash was removed from the game.`); } else if (revealed.outcome.kind === 'ornithopter') { log(g, `${p.name} took the Ornithopter token; it can be spent on a later turn for one movement action.`); } else log(g, `${DISCOVERY_TOKEN_BY_ID[token.face].name} is now a separate, initially unoccupied location within ${territory(token.territory!).name}.`); } function decideDiscoveryDiscard(g: Game, p: Player, action: Action) { const record = g.discoveryStash; requireRule(record?.stage === 'choose' && record.owner === p.id && action.event === record.event && typeof action.card === 'string' && record.hand.includes(action.card) && Object.keys(action).every(key => ['type','event','card'].includes(key)), 'Choose any card from your original stash hand to discard.'); const card = discard(g,p,action.card); record.discarded = card.id; record.stage = 'discard'; record.signature = discoveryStashSignature(record); stageTreacheryDiscard(g,'discovery:stash',[{card,discardedBy:p.id,publicFace:true}], {kind:'discoveryStash',event:record.event,owner:p.id,card:card.id}); log(g, `${p.name} discarded ${card.name} after opening the Treachery Card Stash.`); } function greatMakerRideOptions(g: Game, owner: string) { const p = getPlayer(g,owner), frame = g.greatMaker; if (frame?.stage !== 'ride' || p.faction !== 'fremen') return null; const destinations = gameTerritories(g).flatMap(t => t.sectors.flatMap(sector => { try { allowedEntry(g,p,t.id,sector); return [{territory:t.id,name:t.name,sector}]; } catch (error) { if (error instanceof RuleError) return []; throw error; } })); return {max:p.reserves,eliteMax:p.elites?.reserves ?? 0,destinations}; } function stampGreatMaker(g: Game) { g.greatMaker!.signature = greatMakerSignature(g.greatMaker!); } function greatMakerIntegrity(g: Game) { const frame = g.greatMaker, continuation = g.pendingTreacheryDiscard?.continuation; const controls = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const owned = controls.filter(control => control?.decision?.kind === 'greatMakerVote' || control?.decision?.kind === 'greatMakerRide'); if (!frame) { requireRule(owned.length === 0, 'The Great Maker has lost its original encounter.'); return; } requireRule(g.discoveryEnabled && g.discoveries, 'The Great Maker requires its physical Discovery module.'); nexusRule(() => validateGreatMaker(frame, g.turn, g.players)); if (frame.stage === 'complete') { requireRule(owned.length === 0, 'The completed Great Maker cannot retain another vote or reserve ride.'); return; } if (frame.arrival) requireRule(validGameLocation(g,frame.arrival.territory,frame.arrival.sector), 'The Great Maker has lost its declared reserve destination.'); const card = g.spiceDiscard[frame.pile][frame.index]; requireRule(g.phase === 1 && frame.turn === g.turn && g.spiceSequence?.pile === frame.pile && card && 'worm' in card && card.greatMaker && !card.suppressed && frame.order.join('|') === g.order.join('|'), 'The Great Maker has lost its current spice encounter.'); if (frame.stage === 'vote' || frame.stage === 'ride') { const kind = frame.stage === 'vote' ? 'greatMakerVote' : 'greatMakerRide'; const owner = frame.stage === 'vote' ? frame.order[frame.votes.length] : byFaction(g, 'fremen')?.id; requireRule(owned.length === 1 && owned[0]?.decision?.kind === kind && owned[0].decision.player === owner && owned[0].decision.event === frame.event, 'The Great Maker has lost its owned vote or reserve ride.'); } else { requireRule(owned.length === 0, 'Resolve the current Great Maker interaction before voting or riding.'); const responses = controls.flatMap(control => { const pending = control && 'pendingKarama' in control ? control.pendingKarama as Game['pendingKarama'] : null; return [control?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; }); if (frame.stage === 'worm') requireRule( controls.some(control => control?.decision?.kind === 'wormProtection' && control.decision.territory === frame.territory && control.decision.player === byFaction(g,'fremen')?.id && control.decision.ally === byFaction(g,'fremen')?.ally) || responses.some(response => (response?.kind === 'wormSurvival' || response?.kind === 'wormAllyProtection') && response.location === frame.territory && response.owner === byFaction(g,'fremen')?.id && (!response.recipient || response.recipient === byFaction(g,'fremen')?.ally)), 'The Great Maker has lost its pending worm survival or protection interaction.'); if (frame.stage === 'arrival') requireRule( controls.some(control => control?.decision?.kind === 'intrusion' && control.decision.wormRide && control.decision.player === byFaction(g,'beneGesserit')?.id && control.decision.territory === frame.arrival!.territory) || responses.some(response => response?.kind === 'advisorFlip' && response.advisorResume === 'wormRide' && response.owner === byFaction(g,'beneGesserit')?.id && response.location === frame.arrival!.territory) || (g.pendingAmbassador?.resume === 'wormRide' && g.pendingAmbassador.territory === frame.arrival!.territory && g.pendingAmbassador.sector === frame.arrival!.sector) || (g.pendingTerrorEntry?.resume === 'wormRide' && g.pendingTerrorEntry.territory === frame.arrival!.territory && g.pendingTerrorEntry.sector === frame.arrival!.sector), 'The Great Maker has lost its reserve arrival interaction.'); } } function openGreatMakerVote(g: Game) { const frame = g.greatMaker!; frame.stage = 'vote'; stampGreatMaker(g); g.decision = {kind:'greatMakerVote', player:frame.order[frame.votes.length], event:frame.event}; } function completeGreatMaker(g: Game) { g.greatMaker!.stage = 'complete'; stampGreatMaker(g); continueSpice(g); } function decideGreatMakerVote(g: Game, p: Player, action: Action) { const frame = g.greatMaker!; requireRule(frame?.stage === 'vote' && frame.event === action.event && frame.order[frame.votes.length] === p.id && typeof action.yes === 'boolean' && Object.keys(action).every(key => ['type','event','yes'].includes(key)), 'Cast the current Great Maker vote in storm order.'); frame.votes.push({player:p.id, yes:action.yes}); log(g, `${p.name} voted ${action.yes ? 'yes' : 'no'} to a Great Maker Nexus.`); if (frame.votes.length < frame.order.length) { openGreatMakerVote(g); return; } if (greatMakerMajority(frame)) { g.nexus = true; markNexusOccurred(g); log(g, 'A majority voted yes: the Great Maker produces a Nexus.'); } else log(g, 'The Great Maker vote has no yes majority, so this encounter adds no Nexus.'); const fremen = byFaction(g, 'fremen'); if (fremen && fremen.reserves > 0) { frame.stage = 'ride'; stampGreatMaker(g); g.decision = {kind:'greatMakerRide', player:fremen.id, event:frame.event}; } else completeGreatMaker(g); } function decideGreatMakerRide(g: Game, p: Player, action: Action) { const frame = g.greatMaker!; requireRule(frame?.stage === 'ride' && frame.event === action.event && p.faction === 'fremen' && typeof action.accept === 'boolean', 'Choose the current Great Maker reserve ride.'); if (!action.accept) { requireRule(Object.keys(action).every(key => ['type','event','accept'].includes(key)), 'Declining a ride selects no forces or destination.'); log(g, `${p.name} left the optional Great Maker riders in reserves.`); completeGreatMaker(g); return; } requireRule(Object.keys(action).every(key => ['type','event','accept','territory','sector','amount','elite'].includes(key)), 'Choose only a destination and your Great Maker reserve forces.'); const to = stringField(action.territory), sector = integer(action.sector,0,18,'Sector'); const amount = integer(action.amount,1,p.reserves,'Riding forces'); const elite = eliteChoice(amount,p.reserves,p.elites?.reserves ?? 0,action.elite); allowedEntry(g,p,to,sector); withdrawNativeReserves(g,p,amount,elite); place(p,to,sector,amount,elite); frame.arrival = {territory:to,sector,amount,elite}; frame.stage = 'arrival'; stampGreatMaker(g); log(g, `${p.name} rode the Great Maker with ${amount} reserve forces to ${territory(to).name}. This spends no shipment, movement or spice.`); const intruded = intrusion(g,p,to,{wormRide:true}); if (openTerritoryEntry(g,p,to,sector,amount,elite,'wormRide','wormRide') || intruded) return; completeGreatMaker(g); } function blowSpice(g: Game, thumper = false) { g.nexus = !!g.summonedBeforeBlow; if (!g.summonedBeforeBlow) g.wormRides = []; g.summonedBeforeBlow = false; g.spiceResolution = { skipped: [] }; g.spiceSequence = { pile: 0, skipped: [] }; continueSpice(g, thumper ? { worm: true, thumper: true } : undefined); } function continueSpice(g: Game, injected?: SpiceCard) { g.spiceSequence ??= { pile: 0, skipped: [...(g.spiceResolution?.skipped ?? [])], }; const sequence = g.spiceSequence; let previous = g.spiceDiscard[sequence.pile].findLast( (c) => !('sandtrout' in c) && !('worm' in c && c.suppressed), ); let doubleNext = false; let card = injected ?? drawSpice(g); while (card && !('territory' in card)) { if ('sandtrout' in card) { g.sandtrout = true; for (const p of g.players) p.ally = null; for (const [donor, credit] of Object.entries(g.aid)) getPlayer(g, donor).spice += credit.amount; g.aid = {}; g.allianceOffers = {}; log( g, 'Sandtrout broke all alliances and waits for the next Shai-Hulud.', ); doubleNext = false; card = drawSpice(g); continue; } doubleNext = false; if (g.turn === 1) { if (!card.thumper) sequence.skipped.push(card); } else if (g.sandtrout) { g.sandtrout = false; g.spiceDiscard[sequence.pile].push( { sandtrout: true }, { ...card, suppressed: true }, ); doubleNext = true; log( g, 'Sandtrout suppressed Shai-Hulud. Only an immediate territory replacement receives double spice.', ); } else if (card.greatMaker) { g.spiceDiscard[sequence.pile].push(card); g.greatMaker = {arrival:null,event:crypto.randomUUID(),turn:g.turn,pile:sequence.pile, index:g.spiceDiscard[sequence.pile].length-1, territory:previous && 'territory' in previous ? previous.territory : null, stage:'worm',order:[...g.order],votes:[],ridesBefore:[...g.wormRides],signature:''}; stampGreatMaker(g); if (g.greatMaker.territory) beginWorm(g,g.greatMaker.territory); else openGreatMakerVote(g); return; } else { g.nexus = true; markNexusOccurred(g); g.spiceDiscard[sequence.pile].push(card); if (previous && 'worm' in previous && g.advanced) { const fremen = byFaction(g, 'fremen'); if (fremen && g.wormPlacementCanceledTurn !== g.turn) { g.decision = { kind: 'wormPlacement', player: fremen.id }; return; } } if (previous && 'territory' in previous) { beginWorm(g, previous.territory); return; } previous = card; } card = drawSpice(g); } if (card && 'territory' in card) { if (card.discovery) resolveDiscoveryBlow(g, card); g.spiceDiscard[sequence.pile].push(card); const amount = card.amount * (doubleNext ? 2 : 1); g.spiceWindow = { ...card, amount, harvested: false }; const placement = quoteSpicePlacement(g.spice, g.storm, { ...card, amount }); if (!placement.blocked) { g.spice[placement.key] = placement.total; log(g, `${amount} spice appeared in ${territory(card.territory).name}.`); } else log(g, 'The spice blow was lost to the storm.'); } g.spiceResolution = null; g.ready = []; if (g.spiceWindow) return; finishSpiceWindow(g); } function finishSpiceWindow(g: Game) { g.spiceWindow = null; if (g.nexus) { g.ready = []; log(g, 'Nexus: alliances may be formed or broken.'); } else finishSpicePass(g); } function finishSpicePass(g: Game) { if (g.advanced && g.spiceSequence?.pile === 0) { g.spiceSequence.pile = 1; g.spiceResolution = { skipped: [] }; g.ready = []; continueSpice(g); } else { if (g.spiceSequence?.skipped.length) g.spiceDeck = shuffle([...g.spiceDeck, ...g.spiceSequence.skipped]); g.spiceSequence = null; nextPhase(g); } } function nextWormRide(g: Game) { if (g.greatMaker?.stage === 'arrival') { completeGreatMaker(g); return; } if (g.summonedNexusBeforeRides) { g.summonedNexusBeforeRides = false; g.nexus = true; g.ready = []; return; } const fremen = byFaction(g, 'fremen'); if (fremen?.fremenNexusWormBlockedTurn === g.turn) g.wormRides.length = 0; while (g.wormRides.length) { const t = g.wormRides.shift()!; if ( fremen && Object.entries(fremen.forces).some( ([key, count]) => count > 0 && splitLocation(key).territory === t && splitLocation(key).sector !== g.storm, ) ) { g.decision = { kind: 'wormRide', player: fremen.id, territory: t }; return; } } for (const ride of g.nexusFremenCunningRides ?? []) { if (ride.stage !== 'queued' || ride.occurrence.turn !== g.turn) continue; if (fremen?.fremenNexusWormBlockedTurn === g.turn) { ride.stage = 'complete'; continue; } const source = nexusRule(() => quoteFremenCunningRide( g, ride.authorization.owner, ride.occurrence, ride.authorization, )); if (!source) { ride.stage = 'complete'; log(g, 'Fremen Nexus Cunning has no remaining legal desert force source.'); continue; } ride.stage = 'select'; g.decision = { kind: 'nexusFremenCunningRide', player: ride.authorization.owner, event: ride.occurrence.event }; return; } g.nexus = false; finishSpicePass(g); } function auctionEligible(g: Game) { return g.order.filter( (id) => getPlayer(g, id).hand.length < handLimit(getPlayer(g, id)), ); } function richeseDecision( g: Game, kind: | 'richeseBlackMarket' | 'richeseDeclaration' | 'richeseCache' | 'richeseUnbid', ) { const round = g.richeseBidding!; round.event = crypto.randomUUID(); g.decision = { kind, player: round.owner }; g.active = round.owner; } function beginRicheseBidding(g: Game) { const owner = byFaction(g, 'richese')!; requireRule( g.richeseCache, 'This saved Richese game needs its verified cache inventory.', ); g.richeseBidding = { owner: owner.id, event: '', turn: g.turn, stage: g.advanced && owner.hand.length ? 'blackMarketOffer' : 'declaration', position: null, normalCount: null, blackMarketSold: false, cacheCanceled: false, }; g.auction = null; g.richeseAuction = null; g.richeseFunding = {}; if (g.richeseBidding.stage === 'blackMarketOffer') richeseDecision(g, 'richeseBlackMarket'); else richeseDeclaration(g); } function richeseDeclaration(g: Game) { richeseDeclarationCache(g); const round = g.richeseBidding!; round.stage = 'declaration'; round.normalCount = Math.max( 0, auctionEligible(g).length - 1 - Number(round.blackMarketSold), ); richeseDecision(g, 'richeseDeclaration'); } function prepareRicheseNormal(g: Game) { g.richeseBidding!.stage = 'normal'; setAuction(g); } function offerRicheseCache(g: Game) { g.richeseBidding!.stage = 'cacheOffer'; richeseDecision(g, 'richeseCache'); } function finishNormalBidding(g: Game) { const round = g.richeseBidding; if (round?.turn === g.turn && round.stage === 'normal') { if (round.position === 'last' && !round.cacheCanceled) { offerRicheseCache(g); return; } round.stage = 'complete'; } nextPhase(g); } function richeseLotCard(g: Game): Card | undefined { const lot = g.richeseAuction; if (!lot) return undefined; const cards = lot.source === 'cache' ? g.richeseCache : getPlayer(g, lot.owner).hand; return cards?.find((card) => card.id === lot.cardId); } function richeseDeclarationCache(g: Game) { try { requireRicheseDeclarationCache(g.richeseCache?.length); } catch (error) { if (error instanceof RicheseSettlementError) throw new RuleError(error.message); throw error; } } function richeseSettlementQuote( g: Game, lot = g.richeseAuction!, spendingOwner?: string, ) { const owner = getPlayer(g, lot.owner); const winner = lot.outcome?.kind === 'sold' ? getPlayer(g, lot.outcome.winner) : null; const seat = (p: Player) => ({ id: p.id, handCount: p.hand.length - Number(p.id === spendingOwner), handLimit: handLimit(p), }); try { return quoteRicheseSettlement({ lot, owner: seat(owner), winner: winner ? { ...seat(winner), spice: winner.spice } : null, card: richeseLotCard(g), funding: winner ? g.richeseFunding?.[winner.id] : null, allyCredit: winner ? aidFor(g, winner) : null, }); } catch (error) { if (error instanceof RicheseSettlementError) throw new RuleError(error.message); throw error; } } function finishRicheseLot(g: Game) { const lot = g.richeseAuction!; const round = g.richeseBidding!; if (lot.source === 'blackMarket') { richeseDeclarationCache(g); round.blackMarketSold = lot.outcome?.kind === 'sold'; if (lot.method === 'normal') { const opener = lot.order[0]; round.opener = (g.order.indexOf(opener) + 1) % g.order.length; } } g.richeseAuction = null; g.richeseFunding = {}; g.richesePeekKnown = false; g.richeseClaim = null; g.richeseOfferedCard = null; g.currentAuctionSale = null; if (lot.source === 'blackMarket') richeseDeclaration(g); else if (round.position === 'first') prepareRicheseNormal(g); else { round.stage = 'complete'; nextPhase(g); } } function settleRicheseLot(g: Game) { const lot = g.richeseAuction!; const quote = richeseSettlementQuote(g); if (quote.kind === 'open') return; const owner = getPlayer(g, lot.owner); if (quote.kind !== 'sold') { g.richeseFunding = {}; if (quote.kind === 'retainBlackMarket') { log( g, `${owner.name} retained the unbid Black Market card. No sale occurred, so it does not reduce the normal auction pool.`, ); finishRicheseLot(g); } else if (quote.kind === 'removeCache') { const card = quote.card; g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id); (g.richeseRemoved ??= []).push(card); log( g, `${owner.name} removed ${card.name} from the game after no spice was bid; a full hand prevents keeping it.`, { faction: 'richese', name: 'Unbid cache card' }, ); finishRicheseLot(g); } else richeseDecision(g, 'richeseUnbid'); return; } const winner = getPlayer(g, quote.winner), card = quote.card; payWithAlly(g, winner, quote.amount, quote.allyPayment); g.richeseFunding = {}; if (lot.source === 'cache') g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id); else owner.hand = owner.hand.filter((c) => c.id !== card.id); winner.hand.push(card); g.currentAuctionSale = { winner: winner.id, amount: quote.amount, free: false, origin: lot.source, seller: owner.id, ...stampAuctionBureaucrat(g,winner,quote.amount,quote.allyPayment,owner.id), }; log( g, `${winner.name} bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'} for ${quote.amount} spice. The funded bid was paid automatically.`, { faction: winner.faction, name: 'Auction payment' }, ); const ixians = byFaction(g, 'ixians'); if (lot.source === 'blackMarket' && ixians && winner.ally === ixians.id) { g.pendingIxAlly = { player: winner.id, card: card.id, free: false }; g.decision = { kind: 'ixAllyCard', player: winner.id }; } else continueAuctionSale(g, false); } function richeseOfferBlock(g: Game): string | null { const ixians = byFaction(g, 'ixians'); return g.advanced && ixians?.hand.length && g.ixTechnologyTurn !== g.turn ? 'Ixian Technology on Richese lots is awaiting a ruling on replacement-card custody.' : null; } function ixRicheseTechnologyFrame(g: Game): string { return JSON.stringify({ round: g.richeseBidding, order: g.order, positions: normalizedPlayerPositions(g) }); } function ixRicheseTechnologySignature(pending: NonNullable): string { return JSON.stringify({ ...pending, signature: undefined }); } function ixRicheseTechnologyIntegrity(g: Game) { const pending = g.pendingIxRicheseTechnology; const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'ixRicheseTechnology'); if (pending === undefined && g.ixRicheseTechnologyEvent === undefined && !decisions.length) return; const round = g.richeseBidding, ixians = byFaction(g, 'ixians'), owner = byFaction(g, 'richese'); requireRule(pending && g.status === 'playing' && g.phase === 3 && g.advanced && ixians && g.ixTechnologyTurn !== g.turn && owner && round && pending.player === ixians.id && pending.owner === owner.id && pending.round === round.event && pending.turn === g.turn && round.turn === g.turn && round.owner === owner.id && typeof pending.event === 'string' && pending.event.length > 0 && pending.event === g.ixRicheseTechnologyEvent && ['cache', 'blackMarket'].includes(pending.source) && round.stage === (pending.source === 'cache' ? 'cacheOffer' : 'blackMarketOffer') && !g.auction && !g.richeseAuction && !g.ixAuction && !g.currentAuctionSale && !g.richeseOfferedCard && pending.frame === ixRicheseTechnologyFrame(g) && pending.signature === ixRicheseTechnologySignature(pending), 'The saved Ixian Richese-lot choice lost or changed its original offer, round or event.'); requireRule(['normal', 'onceAround', 'silent'].includes(pending.method) && (pending.source !== 'cache' || pending.method !== 'normal') && (pending.direction === null || ['clockwise', 'counterclockwise'].includes(pending.direction)) && (pending.method !== 'onceAround' || pending.direction !== null) && (pending.source === 'cache' ? pending.claim === null : pending.claim === null || typeof pending.claim === 'string' && pending.claim === pending.claim.trim() && pending.claim.length <= 300), 'The saved Ixian Richese-lot choice has invalid auction terms.'); const held = (pending.source === 'cache' ? g.richeseCache : owner.hand)?.filter(c => c.id === pending.card?.id); requireRule(held?.length === 1 && JSON.stringify(held[0]) === JSON.stringify(pending.card) && physicalTreacheryCards(g).filter(c => c.id === pending.card.id).length === 1, 'The pending Richese offer must retain its exact unique card in the original custody.'); requireRule(decisions.length === 1 && decisions[0].player === pending.player && decisions[0].event === pending.event && decisions[0].source === pending.source && Object.keys(decisions[0]).every(key => ['kind', 'player', 'event', 'source'].includes(key)), 'The saved Ixian Richese-lot choice lost or duplicated its original decision.'); } function beginRicheseLot( g: Game, source: 'cache' | 'blackMarket', action: Action, technologyDeclined = false, ) { const round = g.richeseBidding!, owner = getPlayer(g, round.owner); const card = (source === 'cache' ? g.richeseCache : owner.hand)?.find( (c) => c.id === action.card, ); requireRule(card, 'Choose an available card from the correct collection.'); requireRule( typeof action.method === 'string' && ['normal', 'onceAround', 'silent'].includes(action.method), 'Choose an auction method.', ); requireRule( source !== 'cache' || action.method !== 'normal', 'A Richese cache lot uses Once Around or Silent.', ); requireRule( action.direction === undefined || (typeof action.direction === 'string' && ['clockwise', 'counterclockwise'].includes(action.direction)), 'Choose a physical bidding direction.', ); requireRule( action.method !== 'onceAround' || action.direction !== undefined, 'Choose a physical bidding direction.', ); if (richeseOfferBlock(g) && !technologyDeclined) { requireRule(!g.pendingIxRicheseTechnology && g.ixRicheseTechnologyEvent === undefined, 'Finish the existing Ixian choice before declaring another Richese offer.'); const event = crypto.randomUUID(), player = byFaction(g, 'ixians')!.id; const pending: NonNullable = { event, player, owner: owner.id, round: round.event, turn: g.turn, source, card: structuredClone(card), method: action.method as RicheseAuction['method'], direction: (action.direction as 'clockwise' | 'counterclockwise' | undefined) ?? null, claim: source === 'blackMarket' && typeof action.claim === 'string' ? action.claim.trim().slice(0, 300) : null, frame: ixRicheseTechnologyFrame(g), signature: '', }; pending.signature = ixRicheseTechnologySignature(pending); g.pendingIxRicheseTechnology = pending; g.ixRicheseTechnologyEvent = event; g.decision = { kind: 'ixRicheseTechnology', player, event, source }; g.active = player; log(g, `${getPlayer(g, player).name} must choose whether to continue without exchanging this Richese ${source === 'cache' ? 'cache' : 'Black Market'} lot. The exchange is not yet implemented; Technology remains available for later lots.`); return; } const positions = normalizedPlayerPositions(g); let order = [...g.order]; if (action.method === 'onceAround') { order.sort((a, b) => positions[a] - positions[b]); if (action.direction === 'clockwise') order.reverse(); const at = order.indexOf(owner.id); order = [...order.slice(at + 1), ...order.slice(0, at + 1)]; } const lot = createRicheseAuction({ event: crypto.randomUUID(), cardId: card.id, source, owner: owner.id, method: action.method as RicheseAuction['method'], eligible: auctionEligible(g), order, tieOrder: g.order, }); g.richeseAuction = lot; g.richeseOfferedCard = structuredClone(card); g.richeseFunding = {}; g.richesePeekKnown = false; g.richeseClaim = source === 'blackMarket' && typeof action.claim === 'string' ? action.claim.trim().slice(0, 300) : null; round.stage = 'lot'; g.active = lot.active; log( g, source === 'cache' ? `${owner.name} revealed ${card.name} for a ${lot.method === 'silent' ? 'Silent' : 'Once Around'} cache auction.` : `${owner.name} offered a concealed hand card on the Black Market using ${lot.method === 'normal' ? 'normal bidding' : lot.method === 'silent' ? 'Silent bidding' : 'Once Around bidding'}.${g.richeseClaim ? ` Seller’s unverified claim: “${g.richeseClaim}”` : ''}`, ); if (source === 'blackMarket') g.response = { kind: 'richeseBlackMarket', owner: owner.id, passed: [] }; else settleRicheseLot(g); } function decideRichese(g: Game, p: Player, decision: Decision, action: Action) { const round = g.richeseBidding; requireRule( g.phase === 3 && round && round.owner === p.id && action.event === round.event, 'This Richese bidding decision has expired.', ); if (decision.kind === 'richeseBlackMarket') { requireRule( round.stage === 'blackMarketOffer' && g.advanced, 'Black Market is not available now.', ); if (action.decline === true) richeseDeclaration(g); else beginRicheseLot(g, 'blackMarket', action); } else if (decision.kind === 'richeseDeclaration') { requireRule( round.stage === 'declaration' && typeof action.position === 'string' && ['first', 'last'].includes(action.position), 'Announce whether the cache auction will be first or last.', ); round.position = action.position as 'first' | 'last'; log( g, `${p.name} announced the Richese cache auction ${round.position}, before normal pool preparation. ${round.normalCount} normal lots are scheduled after the cache reduction${round.blackMarketSold ? ' and completed Black Market sale' : ''}.`, ); g.response = { kind: 'richeseAuction', owner: p.id, passed: [] }; } else if (decision.kind === 'richeseCache') { requireRule( round.stage === 'cacheOffer', 'The cache offer is not available now.', ); beginRicheseLot(g, 'cache', action); } else if (decision.kind === 'richeseUnbid') { requireRule( g.richeseAuction?.source === 'cache' && g.richeseAuction.outcome?.kind === 'unbid', 'There is no unbid cache card to resolve.', ); requireRule( typeof action.keep === 'boolean', 'Keep the card or remove it from the game.', ); const card = richeseLotCard(g)!; requireRule( !action.keep || p.hand.length < handLimit(p), 'There is no room in your hand.', ); g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id); if (action.keep) p.hand.push(card); else (g.richeseRemoved ??= []).push(card); log( g, `${p.name} ${action.keep ? 'kept' : 'permanently removed'} ${card.name} after all bids were zero. No purchase bonus applies.`, ); finishRicheseLot(g); } } function setAuction(g: Game, cards?: Card[]) { const richese = byFaction(g, 'richese'); if (!cards && richese && g.richeseBidding?.turn !== g.turn) { beginRicheseBidding(g); return; } const eligible = g.order.filter((id) => { const p = getPlayer(g, id); return p.hand.length < handLimit(p); }); if (!eligible.length) { if (cards?.length) g.deck = [...cards, ...g.deck]; g.auction = null; finishNormalBidding(g); return; } const count = g.richeseBidding?.turn === g.turn ? (g.richeseBidding.normalCount ?? eligible.length) : eligible.length; const ixians = byFaction(g, 'ixians'); if (!cards && ixians && eligible.length && count) { g.auction = null; g.active = null; g.ixAuction = { count, cards: [] }; g.ixAuctionKnown = null; g.response = { kind: 'ixAuction', owner: ixians.id, passed: [] }; return; } if (!cards) { cards = []; for (let i = 0; i < count; i++) { const c = draw(g); if (c) cards.push(c); } } if (!eligible.length || !cards.length) { g.auction = null; finishNormalBidding(g); return; } const start = g.richeseBidding?.opener ?? 0; const opener = [...g.order.slice(start), ...g.order.slice(0, start)].find( (id) => eligible.includes(id), )!; g.auction = { cards, index: 0, bid: 0, bidder: null, active: opener, passed: [], opener: g.order.indexOf(opener), }; g.active = opener; offerAuctionTechnology(g); } function offerAuctionTechnology(g: Game) { g.auction!.peekKnown = false; const ixians = byFaction(g, 'ixians'); if ( g.advanced && ixians && ixians.hand.length && g.ixTechnologyTurn !== g.turn ) g.decision = { kind: 'ixTechnology', player: ixians.id }; else offerAuctionPeek(g); } function auctionTechnologyQuote(run: () => T): T { try { return run(); } catch (error) { if (error instanceof IxTechnologyCancellationError) throw new RuleError(error.message); throw error; } } function offerAuctionPeek(g: Game) { const quote = auctionTechnologyQuote(() => quoteNormalAuctionPeek(g)); g.auction!.peekKnown = quote.peekKnown; g.response = quote.response; } function aidFor(g: Game, p: Player) { return p.ally && g.aid[p.ally]?.recipient === p.id ? g.aid[p.ally] : undefined; } function battleAidFor(g: Game, p: Player) { const donor = p.ally ? g.players.find((other) => other.id === p.ally) : undefined; return g.advanced && g.phase === 6 && donor?.faction === 'choam' && donor.ally === p.id && !g.battle?.choamAidBlocked ? aidFor(g, p) : undefined; } function strongholdEffect(g: Game, player: string): StrongholdId | null { return g.advanced && g.battle ? strongholdBenefit( g.strongholdCards, player, g.battle.territory, g.battle.strongholdCopy, ) : null; } function battleSupportCost(g: Game, p: Player, support: number) { return strongholdSupportCost(strongholdEffect(g, p.id), support); } function battleSupportBudget(g: Game, p: Player) { return ( p.spice + (battleAidFor(g, p)?.amount ?? 0) + (strongholdEffect(g, p.id) === 'arrakeen' ? 2 : 0) ); } function battleTieWinner(g: Game) { const b = g.battle!; const owners = [b.attacker, b.defender].filter(id => strongholdEffect(g, id) === 'habbanya_ridge_sietch'); return saphoAggressorRule(() => battleTieOwner(b.attacker, b.defender, battleAggressor(g), owners)); } function saphoAggressorRule(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof SaphoAggressorError) throw new RuleError(error.message); throw error; } } function battleAggressor(g: Game) { const b = g.battle!; return saphoAggressorRule(() => validateSaphoAggressor(b.saphoAggressor, b.saphoAggressorEvents, b, g.turn)); } function saphoAggressorIntegrity(g: Game) { const b = g.battle; if (!b) return; battleAggressor(g); if (!b.saphoAggressor) return; requireRule(g.status === 'playing' && g.phase === 6 && b.preLeader?.event === b.event && g.players.some(p => p.id === b.attacker) && g.players.some(p => p.id === b.defender), 'The saved Sapho aggressor needs its original battle preparation.'); const cards = physicalTreacheryCards(g).filter(card => card.id === 'richese-juice-of-sapho'); requireRule(cards.length === 1 && richeseCardDefinition(cards[0])?.card.effect === 'juiceOfSapho', 'The played Sapho aggressor card has lost its unique physical custody.'); } function strongholdCopyChoices(g: Game, player: string): StrongholdId[] { const controllers = strongholdControllers( g.players, !!g.mobileStronghold?.location, ); return STRONGHOLD_CARDS.filter( (card) => card.id !== MOBILE_STRONGHOLD && controllers[card.id] === player, ).map((card) => card.id); } function mentatRule(quote: () => T): T { try { return quote(); } catch (error) { if (error instanceof MentatQuestionError) throw new RuleError(error.message); throw error; } } function createMentatQuestion(g: Game) { const b = g.battle!; if (b.mentatQuestionVersion !== 1) return; const owner = [b.attacker,b.defender].find(id => nativeLeaderSkill(g,id)?.skill === 'mentat'); if (!owner) return; const trained = nativeLeaderSkill(g,owner)!, target = b.attacker === owner ? b.defender : b.attacker; const event = crypto.randomUUID(); const receipt: MentatQuestionReceipt = {event,battle:b.event!,turn:g.turn,territory:b.territory, owner,target,leader:trained.leader,stage:'name',weapon:null,card:null, hand:mentatRule(() => mentatHand(getPlayer(g,target).hand,g.expansions)),signature:''}; if (!receipt.hand.length) {receipt.stage = 'unavailable';log(g,MENTAT_EMPTY_HAND,{faction:getPlayer(g,owner).faction,name:'Mentat'});} receipt.signature = mentatSignature(receipt); b.mentatQuestion = receipt; b.mentatQuestionEvent = event; } function mentatQuestionIntegrity(g: Game) { requireRule(g.mentatQuestionPreview === undefined || g.mentatQuestionPreview === true, 'The private Mentat preview flag is invalid.'); const history = g.mentatHistory, events = g.mentatHistoryEvents; requireRule(history === undefined && events === undefined || Array.isArray(history) && Array.isArray(events) && history.length === events.length && new Set(events).size === events.length && new Set(history.map(observation => observation?.event)).size === history.length, 'The saved Mentat observations lost their independent event history.'); for (const observation of history ?? []) { requireRule(observation && typeof observation === 'object' && typeof observation.event === 'string' && observation.event.length > 0 && events!.filter(event => event === observation.event).length === 1 && typeof observation.battle === 'string' && observation.battle.length > 0 && Number.isSafeInteger(observation.turn) && observation.turn >= 1 && observation.turn <= g.turn && typeof observation.territory === 'string' && observation.territory.length > 0 && observation.owner !== observation.target && g.players.some(p => p.id === observation.target) && g.players.some(p => p.id === observation.owner && p.leaders.some(l => l.id === observation.leader)) && mentatWeaponNames(g.expansions).includes(observation.weapon) && observation.signature === mentatSignature(observation), 'The saved Mentat observation changed its question, audience or event.'); mentatRule(() => mentatHand([observation.card],g.expansions)); } const b = g.battle, receipt = b?.mentatQuestion; const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'mentatQuestion'); if (!receipt) { requireRule(receipt === undefined && !b?.mentatQuestionEvent && !decisions.length, 'The saved Mentat question lost its original receipt.'); requireRule(b?.mentatQuestionVersion === undefined || b.mentatQuestionVersion === 1 && g.mentatQuestionPreview === true && !!g.leaderSkills, 'The saved Mentat question has an invalid battle version.'); return; } requireRule(b && b.mentatQuestionVersion === 1 && g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) && g.status === 'playing' && g.phase === 6 && receipt.battle === b.event && receipt.turn === g.turn && receipt.territory === b.territory && typeof receipt.event === 'string' && receipt.event.length > 0 && receipt.event === b.mentatQuestionEvent && [b.attacker,b.defender].includes(receipt.owner) && [b.attacker,b.defender].includes(receipt.target) && receipt.owner !== receipt.target && receipt.signature === mentatSignature(receipt), 'The saved Mentat question changed its battle, audience or original event.'); mentatRule(() => mentatHand(receipt.hand,g.expansions)); requireRule(['name','reveal','answered','declined','unavailable'].includes(receipt.stage), 'The saved Mentat question has an invalid stage.'); requireRule(receipt.stage === 'unavailable' ? receipt.hand.length === 0 : receipt.hand.length > 0, 'The saved Mentat opportunity changed its empty-hand boundary.'); if (receipt.stage === 'name' || receipt.stage === 'declined' || receipt.stage === 'unavailable') requireRule(receipt.weapon === null && receipt.card === null, 'The unasked Mentat question cannot contain an answer.'); else { requireRule(typeof receipt.weapon === 'string', 'The saved Mentat weapon question is missing.'); const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions)); requireRule(receipt.stage === 'reveal' ? receipt.card === null : receipt.card && quote.cards.some(card => JSON.stringify(card) === JSON.stringify(receipt.card)), 'The saved Mentat disclosure does not answer its exact original question.'); } const pending = receipt.stage === 'name' || receipt.stage === 'reveal'; requireRule(decisions.length === (pending ? 1 : 0), 'The saved Mentat question lost or reopened its unique decision.'); if (pending) { const assignment = nativeLeaderSkill(g,receipt.owner); requireRule(!b.revealed && !Object.keys(b.plans).length && !Object.keys(b.leaderSkillHidden ?? {}).length && !b.preparation && !g.response && assignment?.skill === 'mentat' && assignment.leader === receipt.leader && getPlayer(g,receipt.owner).leaders.some(l => l.id === receipt.leader && !l.dead && !l.capturedBy && !l.gholaBy) && JSON.stringify(getPlayer(g,receipt.target).hand) === JSON.stringify(receipt.hand), 'The pending Mentat question changed its trainer, timing or reserved private hand.'); for (const card of receipt.hand) requireRule(physicalTreacheryCards(g).filter(c => c.id === card.id).length === 1, 'The pending Mentat response must retain unique physical card custody.'); const decision = decisions[0]; requireRule(decision === g.decision && decision.event === receipt.event && decision.owner === receipt.owner && decision.target === receipt.target && decision.player === (receipt.stage === 'name' ? receipt.owner : receipt.target) && decision.stage === receipt.stage && decision.weapon === receipt.weapon, 'The Mentat decision no longer matches its atomic question and answering player.'); } const observations = (history ?? []).filter(observation => observation.event === receipt.event); requireRule(observations.length === (receipt.stage === 'answered' ? 1 : 0), 'The completed Mentat question lost or duplicated its private observation.'); if (observations.length) { const {stage: _stage,hand: _hand,signature: _signature,...answer} = receipt; const {signature: _observationSignature,...observation} = observations[0]; requireRule(JSON.stringify(answer) === JSON.stringify(observation), 'The Mentat historical answer changed after disclosure.'); } } function projectedMentat(g: Game, viewer: string): MentatView { const receipt = g.battle?.mentatQuestion; const pending = receipt && (receipt.stage === 'name' || receipt.stage === 'reveal') ? receipt : null; return {pending: pending ? {event:pending.event,owner:pending.owner,target:pending.target, player:pending.stage === 'name' ? pending.owner : pending.target,stage:pending.stage as 'name'|'reveal',weapon:pending.weapon, weapons:pending.stage === 'name' && viewer === pending.owner && pending.hand.length ? mentatWeaponNames(g.expansions) : [], cards:pending.stage === 'reveal' && viewer === pending.target ? mentatRule(() => quoteMentatReveal(pending.hand,pending.weapon!,g.expansions)).cards : [], blocked:pending.stage === 'name' && !pending.hand.length ? MENTAT_EMPTY_HAND : null} : null, history:(g.mentatHistory ?? []).filter(observation => [observation.owner,observation.target].includes(viewer)) .map(({signature: _signature,...observation}) => structuredClone(observation))}; } function actMentatQuestion(g: Game, decision: Extract, action: Action) { const receipt = g.battle!.mentatQuestion!; requireRule(action.event === receipt.event, 'Choose the current Mentat question event.'); if (decision.stage === 'name') { if (action.decline === true) { requireRule(Object.keys(action).every(key => ['type','event','decline'].includes(key)), 'Decline only this Mentat question.'); receipt.stage = 'declined'; log(g,`${getPlayer(g,receipt.owner).name} declined the optional Mentat question.`); } else { requireRule(Object.keys(action).every(key => ['type','event','weapon'].includes(key)), 'Name only one specific Mentat weapon.'); const weapon = stringField(action.weapon); mentatRule(() => quoteMentatReveal(receipt.hand,weapon,g.expansions)); receipt.weapon = weapon; log(g,`${getPlayer(g,receipt.owner).name} asked ${getPlayer(g,receipt.target).name} about ${weapon} with Mentat. The card shown is private and need not be played.`); // Every question uses the same target-owned response, so public timing // cannot reveal whether the named weapon was actually held. receipt.stage = 'reveal'; } } else { requireRule(Object.keys(action).every(key => ['type','event','card'].includes(key)), 'Show exactly one held Treachery Card for Mentat.'); const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions)); const card = quote.cards.find(card => card.id === action.card); requireRule(card, 'Choose a held card that answers this Mentat question.'); receipt.card = {...card}; receipt.stage = 'answered'; } if (receipt.stage === 'answered') { const {stage: _stage,hand: _hand,signature: _signature,...answer} = receipt; const observation = {...answer,weapon:receipt.weapon!,card:receipt.card!,signature:''}; observation.signature = mentatSignature(observation); (g.mentatHistory ??= []).push(observation); (g.mentatHistoryEvents ??= []).push(observation.event); log(g,`${getPlayer(g,receipt.target).name} privately showed one Treachery Card to ${getPlayer(g,receipt.owner).name}.`); } receipt.signature = mentatSignature(receipt); beginBattlePowers(g); } function beginBattlePowers(g: Game) { const b = g.battle!; if (b.mentatQuestion?.stage === 'name' || b.mentatQuestion?.stage === 'reveal') { const receipt = b.mentatQuestion; g.decision = {kind:'mentatQuestion',player:receipt.stage === 'name' ? receipt.owner : receipt.target, event:receipt.event,owner:receipt.owner,target:receipt.target,stage:receipt.stage as 'name'|'reveal',weapon:receipt.weapon}; return; } if (g.leaderSkills) { b.leaderSkillHidden ??= {}; const pending = [b.attacker, b.defender].find((id) => nativeLeaderSkill(g,id) && b.leaderSkillHidden![id] === undefined, ); if (pending) { const p = getPlayer(g, pending); const trained = nativeLeaderSkill(g,pending)!; const available = controlledLeaders(g, p).filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory)); if (available.length === 1 && available[0].id === trained.leader && !p.hand.some((c) => c.kind === 'hero')) { b.leaderSkillHidden[pending] = true; log(g, `${p.name} moved the skilled leader behind the shield because it is their only available battle leader.`, {faction:p.faction,name:'Leader Skill'}); beginBattlePowers(g); return; } g.decision = {kind:'leaderSkillVisibility',player:pending,event:b.event!}; return; } } const choam = byFaction(g, 'choam'); if ( g.advanced && choam?.ally && [b.attacker, b.defender].includes(choam.ally) && getPlayer(g, choam.ally).ally === choam.id ) g.decision = { kind: 'choamBattleFunding', player: choam.id }; else { battlePreparation(g, 'voice'); combatResponses(g); } } function beginStrongholdBattle(g: Game) { const b = g.battle!; const owner = g.strongholdCards?.owners.hidden_mobile_stronghold; if ( g.advanced && b.territory === MOBILE_STRONGHOLD && owner && [b.attacker, b.defender].includes(owner) ) { const choices = strongholdCopyChoices(g, owner); if (choices.length > 1) { g.decision = { kind: 'strongholdCopy', player: owner, event: b.event!, choices, }; return; } if (choices.length === 1) { b.strongholdCopy = choices[0]; log( g, `${getPlayer(g, owner).name}'s mobile Stronghold Card copies ${STRONGHOLD_CARDS.find((card) => card.id === choices[0])!.name}, their only other controlled stronghold.`, { faction: getPlayer(g, owner).faction, name: 'Stronghold advantage' }, ); } } beginBattlePowers(g); } function strongholdIntegrity(g: Game) { if (!g.strongholdCards) { requireRule( !g.battle?.strongholdCopy && g.decision?.kind !== 'strongholdCopy', 'A copied Stronghold advantage requires its module.', ); return; } const state = g.strongholdCards; requireRule( g.advanced && Number.isSafeInteger(state.claimedTurn) && state.claimedTurn >= 0 && state.claimedTurn <= g.turn && !!state.owners && Object.keys(state.owners).length === STRONGHOLD_CARDS.length && STRONGHOLD_CARDS.every( (card) => state.owners[card.id] === null || g.players.some((p) => p.id === state.owners[card.id]), ), 'The saved Advanced Stronghold Card ownership is invalid.', ); if (g.decision?.kind === 'strongholdCopy') { const decision = g.decision, b = g.battle; const choices = strongholdCopyChoices(g, decision.player); requireRule( g.status === 'playing' && g.phase === 6 && !!b && b.territory === MOBILE_STRONGHOLD && decision.event === b.event && state.owners.hidden_mobile_stronghold === decision.player && [b.attacker, b.defender].includes(decision.player) && !b.strongholdCopy && Object.keys(b.plans).length === 0 && Array.isArray(decision.choices) && choices.length > 1 && decision.choices.length === choices.length && new Set(decision.choices).size === choices.length && choices.every((choice) => decision.choices.includes(choice)), 'The saved Stronghold choice must match this battle and current control.', ); } if (g.battle?.strongholdCopy) { const b = g.battle, owner = state.owners.hidden_mobile_stronghold; requireRule( !!b.event && b.territory === MOBILE_STRONGHOLD && !!owner && [b.attacker, b.defender].includes(owner) && b.strongholdCopy !== MOBILE_STRONGHOLD && STRONGHOLD_CARDS.some((card) => card.id === b.strongholdCopy), 'The saved copied Stronghold advantage does not belong to this battle.', ); } } function settleStrongholdOwnership(g: Game, quoted?: StrongholdState) { if (!g.strongholdCards || g.strongholdCards.claimedTurn === g.turn) return; requireRule( g.advanced && g.phase === 8, 'Stronghold Cards settle at the end of an Advanced turn.', ); settleAdvisors(g); const before = g.strongholdCards; g.strongholdCards = quoted ?? settleStrongholdCards( before, g.turn, strongholdControllers(g.players, !!g.mobileStronghold?.location), ); for (const card of STRONGHOLD_CARDS) { const owner = g.strongholdCards.owners[card.id]; if (owner === before.owners[card.id]) continue; log( g, owner ? `${getPlayer(g, owner).name} claimed the ${card.name} Stronghold Card for the next turn.` : `${card.name} has no controlling faction; its Stronghold Card was set aside.`, owner ? { faction: getPlayer(g, owner).faction, name: 'Stronghold control' } : undefined, ); } } function pledgeAid(g: Game, p: Player, requested: unknown) { requireRule( p.ally && getPlayer(g, p.ally).ally === p.id, 'Choose a current mutual ally.', ); const current = g.aid[p.id]?.amount ?? 0; requireRule( !( g.richeseAuction?.method === 'silent' && !g.richeseAuction.outcome && Object.hasOwn(g.richeseAuction.sealed, p.ally) && typeof requested === 'number' && requested < current ), 'Ally funding cannot be reduced until the submitted Silent bids are revealed.', ); const committed = g.pendingHomeworldShipment?.player === p.ally ? g.pendingHomeworldShipment.allyPayment : g.phase === 6 ? (g.battle?.plans[p.ally]?.allyPayment ?? 0) : g.auction?.bidder === p.ally ? (g.auction.allyPayment ?? 0) : 0; const amount = integer( requested, g.richeseAuction?.method === 'silent' && !g.richeseAuction.outcome && Object.hasOwn(g.richeseAuction.sealed, p.ally) ? current : Math.max( committed, richeseAllyCommitment(g.richeseAuction, g.richeseFunding ?? {}, p.id), ), uncommittedSpice(g, p) + current, 'Ally funding', ); p.spice += current - amount; g.aid[p.id] = { recipient: p.ally, amount }; log(g, `${p.name} updated the spice available to their ally this phase.`); } function projectedBribes(g: Game, p: Player): BribeOptions { const available = Math.max(0, uncommittedSpice(g, p)); const blocked = bribeTimingBlock(g); const battlePromises = g.battle?.truthPromises?.some(promise => promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]); const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0; // Reuse the authoritative promise checks on an isolated resource-only copy. // No payment, receipt or live continuation is created by this projection. const maximum = !blocked && (battlePromises || shipmentPromises) ? maximumBribe(available, amount => { const probe = structuredClone(g); getPlayer(probe, p.id).spice -= amount; try { if (battlePromises) reconcileBattlePromises(probe, { actor: p.id, action: { type: 'bribe' } }); if (shipmentPromises) reconcileShipmentPromises(probe, { actor: p.id, action: { type: 'bribe' } }); return true; } catch (error) { if (error instanceof RuleError) return false; throw error; } }) : available; return { available, incoming: p.bribes, blocked, targets: g.players.filter(target => target.id !== p.id && target.id !== p.ally).map(target => { let limit = maximum; let limitation: string | null = maximum < available ? 'Some spice must remain available to fulfill your Truthtrance answer.' : null; if (!blocked && limit >= 5) { try { checkBureaucratContributors(g, target.id, [{ payer: p.id, amount: limit }]); } catch (error) { if (!(error instanceof RuleError)) throw error; limit = 4; limitation = error.message; } } return { id: target.id, maximum: limit, limitation }; }), }; } function uncommittedSpice(g: Game, p: Player) { return ( p.spice - richeseOwnCommitment(g.richeseAuction, g.richeseFunding ?? {}, p.id) - (g.auction?.bidder === p.id ? g.auction.bid - (g.auction.allyPayment ?? 0) : 0) - (battleSupportCost(g, p, g.battle?.plans[p.id]?.support ?? 0) - (g.battle?.plans[p.id]?.allyPayment ?? 0)) - (g.battle?.plans[p.id]?.bankerSpice ?? 0) - (g.pendingHomeworldShipment?.player === p.id ? g.pendingHomeworldShipment.cost - g.pendingHomeworldShipment.allyPayment : 0) ); } function contribution(g: Game, p: Player, cost: number, requested?: unknown) { const available = aidFor(g, p)?.amount ?? 0; const { minimum, maximum } = shipmentPaymentBounds(cost, p.spice, available); return integer(requested ?? minimum, minimum, maximum, 'Ally payment'); } function payWithAlly(g: Game, p: Player, cost: number, allyPayment: number) { const credit = aidFor(g, p); requireRule( p.spice >= cost - allyPayment && (!allyPayment || (credit && credit.amount >= allyPayment)), 'The payment is no longer funded.', ); p.spice -= cost - allyPayment; if (credit) credit.amount -= allyPayment; } function auctionBureaucratParties(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) { return {payee:seller && seller !== p.id ? seller : byFaction(g,'emperor')?.id, shares:[{payer:p.id,amount:amount-allyPayment},...(allyPayment ? [{payer:p.ally!,amount:allyPayment}] : [])].filter(share => share.amount > 0)}; } function stampAuctionBureaucrat(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) { const {payee,shares} = auctionBureaucratParties(g,p,amount,allyPayment,seller); return payee && !allyPayment && shares.length === 1 ? stampBureaucratPayment(g,'auction',shares[0].payer,payee,amount) : {}; } function auctionNext(g: Game) { const a = g.auction!; const eligible = g.order.filter((id) => { const p = getPlayer(g, id); return p.hand.length < handLimit(p); }); const next = quoteNormalAuctionNext({ order: g.order, eligible, active: a.active, bid: a.bid, bidder: a.bidder, passed: a.passed, }); if (next.kind !== 'bid') { if (next.kind === 'unbid') { g.deck = [...a.cards.slice(a.index), ...g.deck]; g.auction = null; finishNormalBidding(g); return; } g.decision = { kind: 'auctionPayment', player: next.player }; g.active = next.player; recoverAuctionPayment(g, getPlayer(g, next.player)); return; } a.active = next.player; g.active = a.active; } function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false) { const a = g.auction!; const winner = getPlayer(g, a.bidder!); if (!free) payWithAlly(g, winner, a.bid, a.allyPayment ?? 0); winner.hand.push(a.cards[a.index]); g.currentAuctionSale = { winner: winner.id, amount: a.bid, free, origin: 'normal', seller: null, ...(!free ? stampAuctionBureaucrat(g,winner,a.bid,a.allyPayment ?? 0) : {}), }; log( g, `${faction(winner.faction).name} won a treachery card for ${emperorNexus ? `the Emperor Nexus card after proving ${a.bid} spice` : free ? 'a Karama' : `${a.bid} spice`}.${automatic ? ' The declared spice payment was the only available payment method.' : ''}`, automatic ? { faction: winner.faction, name: 'Auction payment' } : undefined, ); const ixians = byFaction(g, 'ixians'); if (ixians && winner.ally === ixians.id) { g.pendingIxAlly = { player: winner.id, card: a.cards[a.index].id, free }; g.decision = { kind: 'ixAllyCard', player: winner.id }; } else continueAuctionSale(g, free); } function currentAuctionContinuationQuote( g: Game, operation: AuctionContinuationOperation, spending?: { player: string; card: string }, ) { try { return quoteAuctionContinuation( { status: g.status, phase: g.phase, turn: g.turn, advanced: g.advanced, order: g.order, players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, handLimit: handLimit(p), })), auction: g.auction, sale: g.currentAuctionSale, physicalCards: physicalTreacheryCards(g), pendingIxAlly: g.pendingIxAlly, ixTechnologyTurn: g.ixTechnologyTurn, richeseAuction: g.richeseAuction, richeseBidding: g.richeseBidding, richeseCacheCount: g.richeseCache?.length, }, operation, spending, ); } catch (error) { if (error instanceof AuctionContinuationError) throw new RuleError(error.message); throw error; } } function validateAuctionContinuationCancellation( g: Game, response: ResponseWindow, spending?: { player: string; card: string }, ) { if (response.source === 'ambassador') { validateAmbassadorPurchaseResponse(g, response); return null; } if (['ixAllyCard', 'emperorIncome', 'harkonnenBonus'].includes(response.kind)) return currentAuctionContinuationQuote( g, { kind: 'cancel', response }, spending, ); return null; } function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bureaucratDiversion?:number) { if (bureaucratDiversion === undefined && quote.steps.some(step => step.kind === 'sellerCredit') && offerBureaucratPayment(g,g.currentAuctionSale?.bureaucratPayment,{kind:'auction',quote})) return; for (const step of quote.steps) { if (step.kind === 'clearIxAlly') g.pendingIxAlly = null; else { const seller = getPlayer(g, step.player); seller.spice = step.balance-(bureaucratDiversion ?? 0); log( g, `${seller.name} collected ${step.amount-(bureaucratDiversion ?? 0)} spice from the ${quote.sale.origin === 'cache' ? 'Richese cache' : 'Black Market'} sale.`, { faction: 'richese', name: 'Auction income' }, ); } } const next = quote.next; if (next.kind === 'response') g.response = {...next.response,...(next.response.kind === 'emperorIncome' && g.currentAuctionSale?.bureaucratPayment ? {bureaucratPayment:g.currentAuctionSale.bureaucratPayment,bureaucratPaymentEvent:g.currentAuctionSale.bureaucratPaymentEvent} : {})}; else if (next.kind === 'richeseEnd') finishRicheseLot(g); else { g.currentAuctionSale = null; if (next.kind === 'normalEnd') { g.deck = [...next.returned, ...g.deck]; g.auction = null; finishNormalBidding(g); } else { g.auction = next.auction; g.active = next.active; if (next.offer?.kind === 'ixTechnology') g.decision = next.offer; else if (next.offer) g.response = next.offer; } } } function continueAuctionSale(g: Game, free: boolean) { commitAuctionContinuation( g, currentAuctionContinuationQuote(g, { kind: 'sale', free }), ); } function auctionBonus(g: Game) { commitAuctionContinuation( g, currentAuctionContinuationQuote(g, { kind: 'bonus' }), ); } function nextAuction(g: Game) { commitAuctionContinuation( g, currentAuctionContinuationQuote(g, { kind: 'next' }), ); } /** Physical custody only: promise feasibility remains conservatively checked at commit. */ function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null { if (g.pendingIxRicheseTechnology?.card.id === card.id) return 'This card is reserved for the pending Richese lot and Ixian choice.'; if (g.battle?.lateDefense?.[p.id] === card.id) return 'This Portable Snooper is already played and reserved for battle cleanup.'; if (giftReserved(g, p.id, card.id)) return 'This card is reserved for the pending Richese gift.'; if ( !p.hand.some((held) => held.id === card.id) || !canUseAsKaramaRole(g, p, card) ) return 'Choose a Karama in your hand.'; if (retentionReservesCard(g, p.id, card.id)) return 'A card awaiting Moritani alliance battle cleanup cannot be spent as Karama.'; const plan = g.battle?.plans[p.id]; if (plan && [plan.weapon, plan.defense, plan.leader].includes(card.id)) return 'A sealed battle card cannot be spent as Karama.'; if (g.battle && committedPlanElements(g.battle, p.id).some(element => element.value === card.id)) return 'The card committed to prescience must remain available.'; if (card.kind === 'worthless' && g.pendingKarama) return 'A Worthless conversion is already pending.'; return null; } function karamaCard(g: Game, p: Player, id?: unknown) { // Explicit selections retain the specific custody error from spendKarama. const available = p.hand.filter( (c) => canUseAsKaramaRole(g, p, c) && (id === undefined ? !karamaSpendingBlock(g, p, c) : c.id === id), ); return available.find((c) => c.effect === 'karama') ?? available[0]; } function responseCancelCards( g: Game, p: Player, response: ResponseWindow, ): string[] { if (p.id === response.owner) return []; // Do not simulate cancellation here: it can execute RNG and continuations. // A physically spendable card whose Truthtrance feasibility is not proven // remains a possible choice; the authoritative action still checks promises. return p.hand .filter((card) => !karamaSpendingBlock(g, p, card)) .map((card) => card.id); } /** The same conversion can be suspended by an independent private interaction. */ function savedKaramaContexts(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; return [ { pendingKarama: g.pendingKarama, response: g.pendingExchange?.response ?? g.response, }, g.pendingRicheseGift?.resume, g.pendingNullentropy?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null, ].flatMap((context) => context?.pendingKarama ? [ { pending: context.pendingKarama, response: context.response ?? g.pendingExchange?.response, }, ] : [], ); } function savedKaramaConversions(g: Game) { return savedKaramaContexts(g).map((context) => context.pending); } function karamaCanceledSource(g: Game, response: ResponseWindow) { try { return canceledResponseSource(g, response); } catch (error) { if (error instanceof KaramaContextError) throw new RuleError(error.message); throw error; } } /** Locate a conversion behind the controls saved before a summoned worm. * Only explicit parent links count; unrelated live overlays are not parents. */ function controlsContainKarama( g: Game, controls: | Pick | null | undefined, pending: NonNullable, seen = new Set(), ): boolean { if (!controls || seen.has(controls)) return false; seen.add(controls); if (controls.pendingKarama === pending) return true; const parent = controls.response?.kind === 'richeseGift' ? g.pendingRicheseGift?.resume : controls.response?.kind === 'richesePurchaseIncome' ? g.pendingRichesePurchaseIncome?.resume : controls.decision?.kind === 'nullentropy' ? g.pendingNullentropy?.resume : controls.decision?.kind === 'handExchange' ? g.pendingExchange : null; return controlsContainKarama(g, parent, pending, seen); } function karamaSourceGame( g: Game, pending: NonNullable, ) { const worm = g.summonedWorm; return worm && controlsContainKarama(g, worm.resume, pending) ? { ...g, ...worm.resume, summonedWorm: null } : g; } /** Bind the original opportunity, not hands, balances, response passes or seat control. */ function karamaOpportunitySignature( g: Game, pending: NonNullable, ) { g = karamaSourceGame(g, pending); const use = pending.use; requireRule( use && ['cancel', 'shipment', 'purchase', 'auctionPayment'].includes(use.kind), 'The saved Karama conversion does not match its original opportunity.', ); const opportunity = use.kind === 'cancel' ? karamaCanceledSource(g, use.response) : use.kind === 'shipment' ? { active: g.active, recipient: use.recipient, shipped: getPlayer(g, use.recipient).shipped, ...(currentGuildCunning(g,use.recipient) ? {guildCunning:currentGuildCunning(g,use.recipient)} : {}), rate: g.karamaShipping ?? null, movementRemaining: g.movementRemaining ?? null, } : { auction: g.auction, order: g.order, richeseAuction: g.richeseAuction ?? null, sale: g.currentAuctionSale ?? null, }; return JSON.stringify({ owner: pending.owner, use, status: g.status, advanced: g.advanced, turn: g.turn, phase: g.phase, opportunity, }); } function karamaConversionIntegrity(g: Game) { for (const { pending, response } of savedKaramaContexts(g)) { if (pending.opportunity === undefined) continue; requireRule( pending.opportunity && pending.opportunity.kind === (pending.use?.kind === 'cancel' ? 'cancel' : pending.use?.kind === 'shipment' ? 'shipment' : 'auction') && typeof pending.opportunity.signature === 'string' && response?.kind === 'worthlessKarama' && response.owner === pending.owner && g.advanced && getPlayer(g, pending.owner).faction === 'beneGesserit' && pending.opportunity.signature === karamaOpportunitySignature(g, pending), 'The saved Karama conversion no longer matches its original opportunity.', ); if (pending.opportunity.kind === 'auction') normalKaramaAuction( g, getPlayer(g, pending.owner), pending.use.kind === 'auctionPayment', ); } } /** Only live ownership zones; knowledge receipts and old auction prefixes are aliases. */ function physicalTreacheryCards(g: Game) { return [ ...g.players.flatMap((player) => player.hand), ...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), ...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []), ...(g.ornithopter ? [g.ornithopter.card] : []), ...(g.auction?.cards.slice( g.auction.index + Number(g.currentAuctionSale?.origin === 'normal'), ) ?? []), ]; } function ecazTreacheryIntegrity(g: Game) { if (!g.ecazTreachery) { requireRule(!g.recruits, 'Recruits requires the independent Ecaz Treachery Cards variant.'); return; } requireRule(g.ecazTreachery === true, 'Invalid Ecaz Treachery Cards module marker.'); if (g.status === 'lobby') { throw new RuleError( 'Ecaz Treachery Cards can begin only through their gated audit initializer.', ); } const physical = physicalTreacheryCards(g); for (const expected of ecazTreacheryCards()) { const matching = physical.filter((card) => card.id === expected.id); requireRule( matching.length === 1 && !!ecazTreacheryDefinition(matching[0]) && Object.keys(matching[0]).sort().join(',') === 'effect,id,kind,name', 'The Ecaz Treachery Cards inventory is missing, duplicated or malformed.', ); } requireRule( physical.filter((card) => card.id.startsWith('ecaz-')).length === 3, 'The Ecaz Treachery Cards inventory contains an unknown physical card.', ); for (const [player, plan] of Object.entries(g.battle?.plans ?? {})) { if ([plan.weapon, plan.defense].includes('ecaz-reinforcements')) { const owner = getPlayer(g, player); requireRule(plan.leader !== 'ecaz-reinforcements' && g.battle?.lateDefense?.[player] !== 'ecaz-reinforcements' && plan.weapon !== plan.defense && owner.hand.some(isReinforcements) && [plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) && validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)), 'The sealed Reinforcements plan lost its physical card or legal slot.'); currentReinforcementsCost(g, owner); } requireRule(plan.leader !== HARASS_WITHDRAW_CARD && g.battle?.lateDefense?.[player] !== HARASS_WITHDRAW_CARD && !(plan.weapon === HARASS_WITHDRAW_CARD && plan.defense === HARASS_WITHDRAW_CARD), 'Harass & Withdraw occupies exactly one ordinary Battle Plan card slot.'); if ([plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)) { const owner = getPlayer(g, player); requireRule(owner.hand.some(isHarassWithdraw), 'The sealed Harass & Withdraw card must remain in its owner’s hand.'); requireRule([plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) && validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)), 'The sealed Harass & Withdraw plan has an invalid weapon and defense pair.'); currentHarassWithdrawQuote(g, owner, plan); } } if (g.recruits) { requireRule( Object.keys(g.recruits).sort().join(',') === 'card,player,turn' && g.recruits.card === RECRUITS_CARD_ID && g.recruits.turn === g.turn && g.players.some((player) => player.id === g.recruits!.player), 'The Recruits effect has a malformed or expired receipt.', ); requireRule( g.players.every( (player) => Number.isSafeInteger(player.revived) && player.revived >= 0 && Number.isSafeInteger(player.freeForcesRevived) && player.freeForcesRevived! >= 0 && player.freeForcesRevived! <= player.revived, ), 'The active Recruits effect has inconsistent normal revival ledgers.', ); } } function recruitsModeSupported(g: Game) { return ( !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards && !g.discoveryEnabled ); } function ecazAllyAtBattle(g: Game, side: Player, territoryId: string) { const ally = g.players.find(player => player.id === side.ally && player.ally === side.id); return !!ally && (side.faction === 'ecaz' || ally.faction === 'ecaz') && Object.entries(ally.forces).some(([key, amount]) => amount > 0 && splitLocation(key).territory === territoryId); } function reinforcementsModeSupported(g: Game) { const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz'; return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout && (g.expansions.length === 0 || pairedEcaz) && g.players.every(player => { const expansion = faction(player.faction).expansion; return expansion === 'base' || (pairedEcaz && expansion === 'ecaz'); }); } function currentReinforcementsCost(g: Game, p: Player) { requireRule(reinforcementsModeSupported(g), 'Reinforcements requires the standalone Ecaz card variant with classic or Ecaz/Moritani factions.'); if (g.battle) { const other = getPlayer(g, g.battle.attacker === p.id ? g.battle.defender : g.battle.attacker); requireRule(!ecazAllyAtBattle(g, p, g.battle.territory) && !ecazAllyAtBattle(g, other, g.battle.territory), 'Reinforcements with co-present Ecaz allies awaits combined-army battle integration.'); } try { return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0); } catch (error) { if (error instanceof ReinforcementsError) throw new RuleError(error.message); throw error; } } function reinforcementsPreview(g: Game, p: Player) { const battle = g.battle; if (!battle || ![battle.attacker, battle.defender].includes(p.id) || !p.hand.some(isReinforcements)) return null; const normal = Math.min(3, Math.max(0, p.reserves - (p.elites?.reserves ?? 0))); const elite = Math.min(3 - normal, Math.max(0, p.elites?.reserves ?? 0)); let blocked: string | null = null; if (!reinforcementsModeSupported(g)) blocked = 'Reinforcements requires the standalone Ecaz card variant with classic or Ecaz/Moritani factions.'; else if (g.status !== 'playing' || g.phase !== 6 || battle.revealed || battle.plans[p.id]) blocked = 'Choose Reinforcements before sealing your Battle Plan.'; else if (ecazAllyAtBattle(g, p, battle.territory) || ecazAllyAtBattle(g, getPlayer(g, battle.attacker === p.id ? battle.defender : battle.attacker), battle.territory)) blocked = 'Reinforcements with co-present Ecaz allies awaits combined-army battle integration.'; else if (normal + elite < 3) blocked = 'Reinforcements needs three physical forces in your reserves.'; return { blocked, normal, elite }; } function recruitsInteractionPending(g: Game) { return !!( g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingAmbassador || g.pendingCapture || g.battle ); } function recruitsPlayBlock(g: Game, owner: Player, card: Card): string | null { if (!g.ecazTreachery || !ecazTreacheryDefinition(card) || card.id !== RECRUITS_CARD_ID) return 'Choose the physical Recruits card from the enabled Ecaz Treachery Cards variant.'; if (!recruitsModeSupported(g)) return 'This Recruits prototype currently requires a faction-only game without other optional modules.'; if (g.status !== 'playing' || g.phase !== 4) return 'Play Recruits during Revival.'; if (g.recruits?.turn === g.turn) return 'Recruits is already active this turn; a recovered copy cannot be played again.'; const reserved = transferCardBlock(g, owner, card); if (reserved) return reserved; if (recruitsInteractionPending(g)) return 'Finish the current transaction or priority window before playing Recruits.'; for (const player of g.players) { if ( player.freeForcesRevived !== undefined && (!Number.isSafeInteger(player.freeForcesRevived) || player.freeForcesRevived < 0 || player.freeForcesRevived > player.revived) ) return 'Normal force revival history is inconsistent; Recruits cannot safely reprice it.'; if (player.revived > 0 && player.freeForcesRevived === undefined) return 'Earlier normal force revival history is incomplete; Recruits cannot safely reprice it.'; if (player.revived > (player.freeForcesRevived ?? 0)) return 'Recruits after a paid normal force revival awaits a rules ruling.'; } return null; } function recruitsPreview(g: Game, viewer: Player): RecruitsPreview | null { if (!g.ecazTreachery) return null; ecazTreacheryIntegrity(g); const held = viewer.hand.find((card) => card.id === RECRUITS_CARD_ID); const play = held ? { type: 'card' as const, card: RECRUITS_CARD_ID, blocked: recruitsPlayBlock(g, viewer, held) } : null; const active = g.recruits?.turn === g.turn; return { active, ...(active ? { playedTurn: g.recruits!.turn, playedBy: g.recruits!.player } : {}), play, rates: g.order.map((player) => { const target = getPlayer(g, player); const allowance = normalRevivalAllowance(g, target); return { player, freeRate: allowance.freeRate, limit: allowance.limit }; }), grantRevivalBlocked: active ? 'Grant Fremen ally revival before Recruits; changing the free rate after activation awaits a rules ruling.' : null, }; } /** Pure normal-lot prerequisites; free payment deliberately does not require spice. */ function normalKaramaAuction( g: Game, p: Player, payment: boolean, spendingCard = false, ) { const a = g.auction; const seated = (id: string) => g.players.some((player) => player.id === id); requireRule( g.status === 'playing' && g.phase === 3 && a && !g.richeseAuction && !g.currentAuctionSale && Array.isArray(a.cards) && Number.isSafeInteger(a.index) && a.index >= 0 && a.index < a.cards.length && a.cards[a.index]?.id && Number.isSafeInteger(a.bid) && a.bid >= 0 && (a.bidder === null || seated(a.bidder)) && seated(a.active) && g.order.length > 0 && new Set(g.order).size === g.order.length && g.order.every(seated) && Number.isSafeInteger(a.opener) && a.opener >= 0 && a.opener < g.order.length, 'This Karama purchase needs its original unresolved normal auction lot.', ); requireRule( !payment || a.bidder === p.id, 'Only the original winning bidder may choose the auction payment.', ); requireRule( p.hand.length - Number(payment && spendingCard) < handLimit(p), 'Leave room for the committed Karama auction purchase.', ); const card = a.cards[a.index]; const zones = physicalTreacheryCards(g); requireRule( zones.filter((candidate) => candidate.id === card.id).length === 1, 'The reserved normal auction card has conflicting physical custody.', ); } /** Preflight known pre-effect rejection paths without executing a response or drawing. */ function revivalCancellationQuote(g: Game, response: ResponseWindow) { if ( ![ 'choamRevival', 'revivalLimit', 'revivalDiscount', 'earlyRevival', 'foreignGhola', ].includes(response.kind) ) return null; try { return quoteRevivalCancellation(g, response); } catch (error) { if (error instanceof RevivalCancellationError) throw new RuleError(error.message); throw error; } } function movementCancellationQuote(g: Game, response: ResponseWindow) { try { return quoteMovementCancellation(g, response); } catch (error) { if (error instanceof MovementCancellationError) throw new RuleError(error.message); throw error; } } function currentIxAuctionDrawQuote( g: Game, response: ResponseWindow, canceled: boolean, spendingCard?: Card, ) { try { return quoteIxAuctionDraw( { ...g, physicalCards: physicalTreacheryCards(g) }, response, canceled, spendingCard, ); } catch (error) { if (error instanceof IxAuctionDrawError) throw new RuleError(error.message); throw error; } } function ixSubstitutionCancellationQuote( g: Game, response: ResponseWindow, spending?: { player: string; card: string }, ) { if (response.kind !== 'ixSubstitution') return null; try { homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); const quote = quoteIxSubstitutionCancellation( { ...g, combatLocations: combatLocations(g), territories: gameTerritories(g), physicalCards: physicalTreacheryCards(g), }, response, )!; if (!quote.decision) currentBattleAftermathQuote(g, undefined, spending); return quote; } catch (error) { if (error instanceof IxSubstitutionCancellationError) throw new RuleError(error.message); throw error; } } function choamSaleCancellationQuote(g: Game, response: ResponseWindow) { try { return quoteChoamSaleCancellation(g, response); } catch (error) { if (error instanceof ChoamSaleCancellationError) throw new RuleError(error.message); throw error; } } function noFieldCancellationQuote(g: Game, response: ResponseWindow) { if (response.kind !== 'richeseNoField') return null; try { return quoteNoFieldCancellation(g, response); } catch (error) { if (error instanceof NoFieldCancellationError) throw new RuleError(error.message); throw error; } } function terminalCancellationQuote(g: Game, response: ResponseWindow) { try { return validateTerminalCancellation(g, response); } catch (error) { if (error instanceof TerminalCancellationError) throw new RuleError(error.message); throw error; } } function disasterPreflight(run: () => void) { try { run(); } catch (error) { if (error instanceof DisasterPreflightError) throw new RuleError(error.message); throw error; } } function validateDisasterCancellation(g: Game, response: ResponseWindow) { if (response.kind === 'stormProtection') { disasterPreflight(() => validateStormCancellation(g, response)); if (response.resume === 'storm') normalizedPlayerPositions(g); } else if ( ['wormPlacement', 'wormAllyProtection', 'wormSurvival'].includes( response.kind, ) ) disasterPreflight(() => validateWormCancellation(g, response)); } function validateKaramaUse( g: Game, p: Player, use: KaramaUse, spendingCard?: Card, ) { if (use.kind === 'cancel') { requireRule( use.response && use.response.owner !== p.id, 'Karama cancels another faction’s power.', ); karamaCanceledSource(g, use.response); validateAuctionContinuationCancellation( g, use.response, spendingCard ? { player: p.id, card: spendingCard.id } : undefined, ); currentRicheseCancellationQuote(g, use.response); auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, use.response), ); if (use.response.kind === 'ixAuction') currentIxAuctionDrawQuote(g, use.response, true, spendingCard); moritaniAllianceCancellationQuote(g, use.response); if (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response); if (use.response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, use.response); if (use.response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, use.response); if (use.response.kind === 'nexusFremenCunning') currentFremenCunningResponse(g, use.response); if (use.response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, use.response); if (isCombatResponseKind(use.response.kind)) currentCombatResponseQuote(g, { kind: 'response', response: { kind: use.response.kind, owner: use.response.owner }, canceled: true, }); requireRule( use.response.kind !== 'richeseAuction', 'The canceled Richese auction count is awaiting an official ruling or an explicit table interpretation.', ); if ( use.response.kind === 'richeseBlackMarket' || (use.response.kind === 'atreidesAuction' && g.richeseAuction) ) { const lot = g.richeseAuction, round = g.richeseBidding; requireRule( g.phase === 3 && lot && round && round.turn === g.turn && round.stage === 'lot' && round.owner === lot.owner && (use.response.kind === 'richeseBlackMarket' ? lot.source === 'blackMarket' && lot.owner === use.response.owner : getPlayer(g, use.response.owner).faction === 'atreides'), 'This canceled power needs its original Richese auction opportunity.', ); const target = use.response.kind === 'richeseBlackMarket' ? { ...lot, outcome: { kind: 'unbid' as const } } : lot; const quote = richeseSettlementQuote( g, target, spendingCard ? p.id : undefined, ); if (lot.source === 'blackMarket' && quote.kind !== 'open') richeseDeclarationCache(g); } validateDisasterCancellation(g, use.response); revivalCancellationQuote(g, use.response); movementCancellationQuote(g, use.response); choamSaleCancellationQuote(g, use.response); noFieldCancellationQuote(g, use.response); terminalCancellationQuote(g, use.response); ixSubstitutionCancellationQuote( g, use.response, spendingCard ? { player: p.id, card: spendingCard.id } : undefined, ); if (use.response.kind === 'harkonnenTraitor') { const b = g.battle, owner = getPlayer(g, use.response.owner); requireRule( g.phase === 6 && b?.revealed && owner.faction === 'harkonnen' && ![b.attacker, b.defender].includes(owner.id) && traitorVoters(g, b).includes(owner.id) && b.traitorCalls[owner.id] === true, 'The canceled allied traitor call needs its original revealed battle.', ); if (traitorVoters(g, b).every((id) => b.traitorCalls[id] !== undefined)) currentBattleResolutionQuote(g, owner.id); } if (use.response.kind === 'moritaniRetention') validateMoritaniCancellation(g, use.response.owner); validateAftermathCancellation( g, use.response, spendingCard ? { player: p.id, card: spendingCard.id } : undefined, ); if (use.response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,use.response); currentChoamWorthlessCancellationQuote(g, use.response); currentPlacementCancellationQuote(g, use.response); if (use.response.kind === 'ecazCollection') currentEcazCollectionQuote(g, use.response, true); // Canceling a Worthless conversion restores its old use; it does not execute // that use. In particular, a legacy unsupported count attempt is cancelable. } else if (use.kind === 'shipment') { const recipient = getPlayer(g, use.recipient); requireRule( g.status === 'playing' && g.phase === 5 && g.active === recipient.id && shipmentAvailable(g,recipient) && !g.karamaShipping, 'Choose the active player before their unused Karama shipment.', ); } else normalKaramaAuction(g, p, use.kind === 'auctionPayment', !!spendingCard); } /** Owner-promise proof only. A false result means this suffix remains outside * this bounded preview and must retain its existing atomic action boundary. */ function assertKaramaPromiseFeasibility( g: Game, p: Player, use: KaramaUse, costCard?: Card, ): boolean { const battlePromises = g.battle && !g.battle.revealed && !g.battle.plans[p.id] ? (g.battle.truthPromises ?? []).filter( (promise) => promise.player === p.id && !promise.released, ) : []; const shipmentPromises = liveShipmentPromises( g.shipmentPromises ?? [], p.id, g.turn, ); if (!battlePromises.length && !shipmentPromises.length) return true; const projected = structuredClone(g), owner = getPlayer(projected, p.id); if (costCard) owner.hand = owner.hand.filter((card) => card.id !== costCard.id); if (use.kind === 'shipment') { projected.karamaShipping = { player: use.recipient, owner: p.id, card: costCard?.id ?? use.card, }; } else if (use.kind === 'cancel') { const b = projected.battle; switch (use.response.kind) { case 'voice': if (!b) return false; delete b.voice; break; case 'prescience': if (!b) return false; delete b.prescience; delete b.preparation; break; case 'nexusPrescience': if (!b?.nexusInspection) return false; b.nexusInspection = cancelNexusInspection(battleInspectionContext(projected), b.nexusInspection); delete b.preparation; break; case 'nexusSardaukar': if (!b || !currentNexusSardaukar(projected)) return false; finishNexusSardaukar(projected,true); break; case 'eliteStrength': if (!b) return false; (b.eliteBlocked ??= []).push(use.response.owner); break; case 'fremenSupport': if (!b) return false; b.fremenSupportBlocked = true; break; case 'kwisatz': if (!b) return false; b.kwisatzBlocked = true; break; case 'choamBattleAid': if (!b) return false; b.choamAidBlocked = true; break; case 'emperorGift': // Cancellation transfers no spice and changes no owner's plan inputs. break; default: return false; } } else return false; // The intended result is after this response, not a replay of its suffix. projected.response = null; projected.pendingKarama = null; if (battlePromises.length) requireRule( !!findReachableBattlePlan(projected, owner, { promises: battlePromises }), 'You cannot voluntarily make your Truthtrance battle promise impossible.', ); if (shipmentPromises.length) requireRule( !!findShipmentCompletion(projected, owner, shipmentPromises), 'You cannot voluntarily make your Truthtrance shipment answer impossible.', ); return true; } function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) { const blocked = karamaSpendingBlock(g, p, card); requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.'); validateKaramaUse(g, p, use, card); assertKaramaPromiseFeasibility(g, p, use, card); const used = discard(g, p, card.id); if (g.semutaPreview && card.effect === 'karama' && use.kind === 'cancel' && (use.response.kind === 'choamCharity' || use.response.kind === 'choamInflation' || use.response.kind === 'bgCharity')) { const continuation: KaramaResponseDiscardContinuation = { kind: use.response.kind === 'choamCharity' ? 'karamaCharityDiscard' : use.response.kind === 'choamInflation' ? 'karamaInflationDiscard' : 'karamaBgCharityDiscard', player: p.id, card: used.id, use, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { g.response = null; continuation.stateSignature = karamaResponseDiscardSignature(g, continuation); stageTreacheryDiscard(g, `karama:${use.response.kind}`, entries, continuation); return; } } if (g.semutaPreview && card.effect === 'karama' && (use.kind === 'purchase' || use.kind === 'auctionPayment')) { const continuation: KaramaAuctionDiscardContinuation = { kind: use.kind === 'purchase' ? 'karamaPurchaseDiscard' : 'karamaPaymentDiscard', player: p.id, card: used.id, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = karamaAuctionDiscardSignature(g, continuation); stageTreacheryDiscard(g, `karama:${use.kind}`, entries, continuation); return; } } if (use.kind === 'shipment') use = { ...use, card: card.id }; if (card.kind === 'worthless') { requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.'); g.pendingKarama = { owner: p.id, use }; g.pendingKarama.opportunity = { kind: use.kind === 'cancel' ? 'cancel' : use.kind === 'shipment' ? 'shipment' : 'auction', signature: karamaOpportunitySignature(g, g.pendingKarama), }; g.response = { kind: 'worthlessKarama', owner: p.id, passed: [], intent: use.kind === 'cancel' ? `Cancel a power used by ${getPlayer(g, use.response.owner).name}.` : use.kind === 'shipment' ? `Give ${getPlayer(g, use.recipient).name} Guild shipment rates paid to the bank.` : use.kind === 'purchase' ? 'Take the current auction card without paying spice.' : 'Pay the winning auction bid without spending spice.', }; log( g, `${p.name} used ${card.name} as Karama; the faction power awaits responses.`, ); } else completeKarama(g, p, use, card); } function completeKarama(g: Game, p: Player, use: KaramaUse, card?: Card) { validateKaramaUse(g, p, use); const played = card?.effect === 'truthtrance' ? 'Truthtrance as Karama' : 'Karama'; if (use.kind === 'cancel') { g.response = use.response; finishResponse(g, true); log(g, `${p.name} used ${played} to cancel ${use.response.kind}.`); } else if (use.kind === 'shipment') { g.karamaShipping = { player: use.recipient, owner: p.id, card: use.card }; log( g, `${p.name} used ${played} for ${getPlayer(g, use.recipient).name}’s shipment at Guild rates, paid to the bank.`, ); } else { if (use.kind === 'purchase') g.auction!.bidder = p.id; settleAuction(g, true); } } function auctionSpicePaymentFunded(g: Game, p: Player) { const a = g.auction; if (!a || a.bidder !== p.id) return false; const allyPayment = a.allyPayment ?? 0; return ( Number.isInteger(a.bid) && a.bid >= 0 && Number.isInteger(allyPayment) && allyPayment >= 0 && allyPayment <= a.bid && p.spice >= a.bid - allyPayment && (!allyPayment || (aidFor(g, p)?.amount ?? 0) >= allyPayment) ); } function recordFullPlanInspection(g: Game, owner: string) { const p = getPlayer(g, owner); log( g, `${p.name} can now inspect the committed battle plan privately. The plan remains available until both plans are revealed.`, { faction: p.faction, name: 'Full plan inspection' }, ); } function harkonnenInspectionSignature( inspection: Omit, 'signature'>, ) { return JSON.stringify(['harkonnenExchangeInspection', inspection.owner, inspection.target, inspection.turn, inspection.phase, inspection.kind, inspection.cards.map(card => [card.id, card.name, card.kind, card.effect ?? null])]); } function recordHarkonnenInspection(g: Game, owner: string, target: string, kind: 'draw' | 'return', cards: Card[]) { const inspection = { owner, target, turn: g.turn, phase: 3 as const, kind, cards: structuredClone(cards) }; g.harkonnenExchangeInspection = { ...inspection, signature: harkonnenInspectionSignature(inspection) }; } function projectedHarkonnenInspection(g: Game, owner: string) { const inspection = g.harkonnenExchangeInspection; if (inspection?.owner !== owner) return null; return { owner: inspection.owner, target: inspection.target, turn: inspection.turn, phase: inspection.phase, kind: inspection.kind, cards: inspection.cards }; } function harkonnenExchangeIntegrity(g: Game) { const inspection = g.harkonnenExchangeInspection; const physical = inspection || g.pendingExchange ? physicalTreacheryCards(g) : []; if (inspection !== undefined) { requireRule( inspection && typeof inspection === 'object' && Object.keys(inspection).sort().join(',') === 'cards,kind,owner,phase,signature,target,turn' && g.advanced && g.players.some(p => p.id === inspection.owner && p.faction === 'harkonnen' && p.specialKaramaUsed === true) && g.players.some(p => p.id === inspection.target && p.id !== inspection.owner) && Number.isSafeInteger(inspection.turn) && inspection.turn >= 1 && inspection.turn <= g.turn && inspection.phase === 3 && ['draw', 'return'].includes(inspection.kind) && Array.isArray(inspection.cards) && inspection.cards.length >= 1 && inspection.cards.length <= 4 && inspection.cards.every(card => card && typeof card === 'object' && Object.keys(card).every(key => ['id', 'name', 'kind', 'effect'].includes(key)) && typeof card.id === 'string' && typeof card.name === 'string' && physical.filter(current => current.id === card.id && current.name === card.name && current.kind === card.kind && current.effect === card.effect).length === 1) && new Set(inspection.cards.map(card => card.id)).size === inspection.cards.length && inspection.signature === harkonnenInspectionSignature(inspection), 'The private Harkonnen exchange inspection is malformed.', ); } const exchanges = homeworldSavedDecisions(g).filter(decision => decision.kind === 'handExchange'); const pending = g.pendingExchange; if (!pending) { requireRule(!exchanges.length, 'The Harkonnen exchange has lost its suspended continuation.'); return; } requireRule( g.status === 'playing' && g.advanced && g.phase === 3 && Object.keys(pending).sort().join(',') === 'decision,response' && exchanges.length === 1 && pending.decision?.kind !== 'handExchange' && (pending.decision === null || (pending.decision && typeof pending.decision.kind === 'string' && g.players.some(p => p.id === pending.decision!.player))) && (pending.response === null || (pending.response && typeof pending.response.kind === 'string' && g.players.some(p => p.id === pending.response!.owner) && Array.isArray(pending.response.passed) && new Set(pending.response.passed).size === pending.response.passed.length && pending.response.passed.every(id => g.players.some(p => p.id === id)))), 'The saved Harkonnen exchange has an invalid Bidding continuation.', ); const decision = exchanges[0]; const owner = g.players.find(p => p.id === decision.player); const target = g.players.find(p => p.id === decision.target); requireRule( Object.keys(decision).sort().join(',') === 'count,kind,player,target' && owner?.faction === 'harkonnen' && owner.specialKaramaUsed === true && target && target.id !== owner.id && Number.isSafeInteger(decision.count) && decision.count >= 1 && decision.count <= 4 && owner.hand.length >= decision.count && owner.hand.length - decision.count <= handLimit(owner) && target.hand.length + decision.count <= handLimit(target), 'The saved Harkonnen exchange has an invalid owner, target or return count.', ); requireRule( [...owner.hand, ...target.hand].every(card => typeof card.id === 'string' && physical.filter(current => current.id === card.id).length === 1), 'The Harkonnen exchange requires distinct physical cards in their original custody.', ); if (inspection) requireRule( inspection.owner === owner.id && inspection.target === target.id && inspection.turn === g.turn && inspection.cards.length === decision.count, 'The pending Harkonnen return differs from its private inspection.', ); } function finishHarkonnenExchange( g: Game, decision: Extract, returnCards: unknown, automatic = false, ) { const owner = getPlayer(g, decision.player), target = getPlayer(g, decision.target); requireRule( Array.isArray(returnCards) && returnCards.length === decision.count && new Set(returnCards).size === decision.count && returnCards.every(id => typeof id === 'string' && owner.hand.some(card => card.id === id)), 'Return exactly the required number of distinct cards from your hand.', ); const returned = owner.hand.filter(card => returnCards.includes(card.id)); if (automatic && !g.harkonnenExchangeInspection) { // An old save records no original draw. Its forced return is still known exactly. recordHarkonnenInspection(g, owner.id, target.id, 'return', returned); } owner.hand = owner.hand.filter(card => !returnCards.includes(card.id)); target.hand.push(...returned); requireRule(owner.hand.length <= handLimit(owner) && target.hand.length <= handLimit(target), 'The completed exchange must respect hand limits.'); const pending = g.pendingExchange!; g.pendingExchange = null; g.response = pending.response; g.decision = pending.decision; if (!g.response && g.decision?.kind === 'auctionPayment') recoverAuctionPayment(g, getPlayer(g, g.decision.player)); log(g, `${owner.name} returned ${decision.count} cards to ${target.name}${automatic ? ' automatically; every held card was required' : ''}.`, { faction: 'harkonnen', name: 'Harkonnen exchange' }); } function finishAutomaticDecision(g: Game): boolean { if (g.biddingEnd && biddingEndQuiet(g)) return finishBiddingEnd(g); if (advanceHomeworldReveal(g)) return true; const decision = g.decision; if (decision?.kind === 'handExchange' && !g.response && !g.truthtrance && !g.phaseOpening && !g.pendingNullentropy && !g.pendingRicheseGift && !g.pendingTreacheryDiscard && getPlayer(g, decision.player).hand.length === decision.count) { harkonnenExchangeIntegrity(g); finishHarkonnenExchange(g, decision, getPlayer(g, decision.player).hand.map(card => card.id), true); return true; } if (homeworldShipmentAutomatic(g)) { const shipment = g.pendingHomeworldShipment!; g.decision = null; commitHomeworldShipment(g, shipment); return true; } const market = g.choamMarket; if ( decision?.kind === 'ecazAmbassador' && g.pendingAmbassador?.stage === 'ship' && g.pendingAmbassador.effect === 'guild' && decision.player === g.pendingAmbassador.beneficiary && !g.response && !g.pendingKarama && !g.pendingRicheseGift && !g.pendingExchange && !g.summonedWorm ) { const { beneficiary } = currentGuildAmbassador(g); const choices = guildAmbassadorRule(() => guildAmbassadorShipments(g, beneficiary.id), ); if (!choices.maximum || !choices.destinations.length) { g.decision = null; log( g, `${beneficiary.name} has no available physical Guild Ambassador shipment. The committed trigger finishes automatically.`, ); finishAmbassador(g); return true; } } if ( decision?.kind === 'ecazAmbassador' && g.pendingAmbassador?.stage === 'move' && g.pendingAmbassador.effect === 'fremen' && decision.player === g.pendingAmbassador.beneficiary && !g.response && !g.pendingKarama && !g.pendingRicheseGift && !g.pendingExchange && !g.summonedWorm && !fremenAmbassadorMovement(g, decision.player).sources.length ) { currentFremenAmbassador(g); g.decision = null; offerAmbassadorRelocation(g); return true; } if ( decision?.kind === 'choamMarket' && market?.owner === decision.player && g.status === 'playing' && g.phase === 3 && !g.response && !g.truthtrance && !g.phaseOpening && !g.pendingNullentropy && !g.pendingRicheseGift && !g.pendingKarama && !market.sale && !market.trade && getPlayer(g, market.owner).hand.length === 0 && g.players.every( (p) => p.id === market.owner || (p.hand.length < 2 && !( p.faction === 'richese' && p.ally === market.owner && getPlayer(g, market.owner).ally === p.id && p.hand.length > 0 )), ) ) { // Hand counts are public only in Bidding. Nonempty hands may prepare sales, while another // two-card hand could use Distrans or a Richese ally could give one card. log( g, `${getPlayer(g, market.owner).name} has no cards and no incoming card exchange is available. The end-of-phase market finishes automatically.`, { faction: 'choam', name: 'Market complete' }, ); finishChoamMarket(g); return true; } if (decision?.kind === 'choamAudit' && currentAuditCount(g) === 0) { finishAuditor(g, 'empty'); return true; } if ( decision?.kind === 'choamAuditPayment' && (currentAuditCount(g) === 0 || getPlayer(g, decision.player).spice < currentAuditCount(g)) ) { finishAuditor(g, 'inspect'); return true; } const lot = g.richeseAuction; const richese = byFaction(g, 'richese'); if ( !decision && richese && !noFieldAllyOfferBlock(g, richese) && !( g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === richese.ally ) ) { g.richeseAllyOpportunity = { turn: g.turn, recipient: richese.ally! }; g.decision = { kind: 'richeseAllyOpportunity', player: richese.id, recipient: richese.ally!, }; log( g, `${richese.name} may offer a No-Field before their ally’s shipment. The ally’s turn is held for this choice.`, ); return true; } if (g.phase === 3 && !decision && lot && !lot.outcome) { const full = lot.method === 'silent' ? lot.eligible.find( (id) => !Object.hasOwn(lot.sealed, id) && getPlayer(g, id).hand.length >= handLimit(getPlayer(g, id)), ) : lot.active && getPlayer(g, lot.active).hand.length >= handLimit(getPlayer(g, lot.active)) ? lot.active : null; if (full) { g.richeseAuction = submitRicheseBid( lot, { event: lot.event, actor: full, amount: lot.method === 'silent' ? 0 : null, }, 0, ); if (lot.method === 'silent') (g.richeseFunding ??= {})[full] = { amount: 0, allyPayment: 0, donor: null, }; g.active = g.richeseAuction.active; const player = getPlayer(g, full); log( g, `${player.name} automatically ${lot.method === 'silent' ? 'submitted zero' : 'passed'} because their hand became full before bidding.`, { faction: player.faction, name: 'Full hand: no bid' }, ); if (lot.method === 'silent' && g.richeseAuction.outcome) log( g, `Silent bids revealed together: ${g.richeseAuction.tieOrder.map((id) => `${getPlayer(g, id).name} ${g.richeseAuction!.sealed[id]}`).join('; ')}. Ties are resolved in storm order.`, ); settleRicheseLot(g); return true; } } if ( g.phase === 3 && decision?.kind === 'richeseUnbid' && lot?.source === 'cache' && lot.outcome?.kind === 'unbid' && getPlayer(g, lot.owner).hand.length >= handLimit(getPlayer(g, lot.owner)) ) { g.decision = null; settleRicheseLot(g); return true; } if (decision?.kind === 'fullPlanRead') { const b = g.battle; requireRule( b && !b.revealed && b.fullPlan?.owner === decision.player && b.fullPlan.target === decision.target && b.plans[decision.target], 'The saved inspection does not match a committed battle plan.', ); g.decision = null; recordFullPlanInspection(g, decision.player); return true; } if ( decision?.kind === 'auctionPayment' && g.status === 'playing' && g.phase === 3 ) { const p = getPlayer(g, decision.player); // The ally split was committed with the bid. Never silently select a new // split or consume a card on behalf of a player, including a BG conversion. if ( auctionSpicePaymentFunded(g, p) && !p.hand.some((card) => !karamaSpendingBlock(g, p, card)) && !emperorNexusUniformAuctionPayment(g) ) { g.decision = null; settleAuction(g, false, true); return true; } } return false; } function recoverAuctionPayment(g: Game, p: Player) { const a = g.auction!; if (a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0) || karamaCard(g, p)) { g.decision = { kind: 'auctionPayment', player: p.id }; return; } // Provisional recovery for an unfunded canceled payment; primary-source // confirmation is still required before advanced tables are enabled. g.decision = null; a.bid = 0; a.bidder = null; a.allyPayment = 0; a.passed = []; while ( getPlayer(g, g.order[a.opener]).hand.length >= handLimit(getPlayer(g, g.order[a.opener])) ) a.opener = (a.opener + 1) % g.order.length; a.active = g.order[a.opener]; g.active = a.active; log( g, 'The winning bid is no longer funded. Bidding restarts for the same unrevealed card.', ); } function occupancyRule(quote: () => T): T { try { return quote(); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid territory occupancy.', ); } } function sharesEcazOccupation(g: Game, p: Player, to: string) { return ( !!p.ally && occupancyRule( () => ecazOccupancyRelation(g.players, p.id, p.ally!, { kind: 'territory', id: to, }) === 'ecazAlliance', ) ); } function allowedEntry( g: Game, p: Player, t: string, s: number, stormShipment = false, advisors = false, ) { requireRule( t !== MOBILE_STRONGHOLD || g.mobileStronghold?.location, 'The mobile stronghold has not been placed.', ); requireRule( validGameLocation(g, t, s), 'Choose a sector belonging to this territory.', ); requireRule( s === 0 || s !== g.storm || stormShipment, 'That sector is in storm.', ); const blocked = occupancyRule(() => territoryEntryBlock(g.players, p.id, t, advisors), ); requireRule(!blocked, blocked ?? 'This territory cannot be entered.'); } function eliteChoice( total: number, available: number, eliteAvailable: number, value: unknown, ) { const minimum = Math.max(0, total - (available - eliteAvailable)); return integer( value ?? minimum, minimum, Math.min(total, eliteAvailable), 'Elite forces', ); } function forceGroup(g: Game, p: Player, action: Action, maxOrigins = 1) { let entries: [string, unknown][]; if (action.forces !== undefined) { requireRule( action.forces && typeof action.forces === 'object' && !Array.isArray(action.forces), 'Choose a force group from one territory.', ); entries = Object.entries(action.forces); } else entries = [[stringField(action.from), action.amount]]; const group: [string, number][] = []; const eliteGroup: Record = {}; let origin: string | undefined; const origins = new Set(); let noField: { tokenId: string; event: string; from: string } | undefined; if (action.noField !== undefined) { const marker = p.noField?.deployed; requireRule( p.faction === 'richese' && marker && marker.tokenId === action.noField && p.noFieldEvent === action.event, 'This concealed No-Field movement selection is stale.', ); origin = marker.location.territory; origins.add(origin); noField = { tokenId: marker.tokenId, event: p.noFieldEvent!, from: location(marker.location.territory, marker.location.sector), }; } for (const [key, value] of entries) { const src = splitLocation(key); requireRule( validGameLocation(g, src.territory, src.sector), 'Invalid source sector.', ); const n = integer(value, 0, p.forces[key] ?? 0, 'Forces'); if (!n) continue; origins.add(src.territory); requireRule(origins.size <= maxOrigins, maxOrigins === 1 ? 'A force group must come from one territory.' : 'Planetologist gathers forces from exactly two territories.'); origin ??= src.territory; group.push([key, n]); const selectedElite = action.forces !== undefined ? (action.eliteForces as Record | undefined)?.[key] : action.elite; eliteGroup[key] = eliteChoice( n, p.forces[key], p.elites?.forces[key] ?? 0, selectedElite, ); } requireRule( group.length > 0 || noField, 'Choose at least one force or the deployed No-Field.', ); return { group, eliteGroup, elite: Object.values(eliteGroup).reduce((a, b) => a + b, 0), origin: origin!, origins: [...origins], total: group.reduce((n, [, count]) => n + count, 0) + (noField ? 1 : 0), noField, sourceKeys: [ ...group.map(([key]) => key), ...(noField ? [noField.from] : []), ], }; } function movesAllowed(g: Game, p: Player) { const cunning = currentGuildCunning(g,p.id); if (cunning) return p.moved + (cunning.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(cunning.receipt,g.hajr.includes(p.id),cunning.movesAfter)) : 0); const flight = g.ornithopter; return flight?.player === p.id && flight.turn === g.turn ? flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1) : g.hajr.includes(p.id) ? 2 : 1; } function emperorHomeworldMoveBlock(g: Game, p: Player): string | null { if (!g.homeworlds?.custody || !g.advanced || p.faction !== 'emperor') return 'Moving between Kaitain and Salusa requires the Advanced Emperor Homeworlds.'; if ( g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || p.moved >= movesAllowed(g, p) ) return 'Use an available movement during your own Shipment and Movement turn.'; if ( g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingShipment || g.battle ) return 'Finish the current interaction before moving between Homeworlds.'; if (g.ornithopter) return 'Homeworld transfers during an active Ornithopter card await their movement-group integration.'; const richese = byFaction(g, 'richese'); if ( richese && !noFieldAllyOfferBlock(g, richese) && !( g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === p.id ) ) return 'Wait for Richese to offer or pass its allied shipment opportunity.'; if (!p.shipped) { try { checkShipmentPromises(g, p, null); } catch (error) { if (error instanceof RuleError) return error.message; throw error; } } return null; } function emperorHomeworldMoveEvent(g: Game, p: Player) { return JSON.stringify([ g.turn, g.phase, g.active, p.moved, p.shipped, homeworldContext(g), g.homeworlds?.custody, ]); } function ornithopterBlock( g: Game, p: Player, mode: OrnithopterMode, ): string | null { if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id) return 'Use Ornithopter during your own Shipment and Movement turn.'; if (g.truthtrance || g.response || g.decision || g.phaseOpening) return 'Resolve the current interaction before declaring a movement.'; if (g.ornithopter) return 'Finish the already played Ornithopter movement first.'; if (currentGuildCunning(g,p.id)) return 'Guild Cunning combined with the physical Ornithopter card awaits its timing ruling.'; if (p.moved || g.hajr.includes(p.id)) return 'Ornithopter combined with prior moves or Hajr awaits its timing ruling.'; if ( mode === 'range3' && p.faction === 'choam' && g.choamMovement?.turn === g.turn && g.choamMovement.bonus ) return 'Kulon combined with the fixed three-territory card range awaits a ruling.'; return null; } function discoveryFlightIntegrity(g: Game) { const pendingFlights = [g.pendingChoamMove, g.pendingIxMove, g.pendingFremenMove?.order] .filter((move): move is MovementOrder => !!move && move.discoveryFlight !== undefined); requireRule(pendingFlights.length <= 1, 'One Discovery Ornithopter cannot be reserved for multiple pending movements.'); for (const move of pendingFlights) nexusRule(() => validateDiscoveryFlight(g, move.discoveryFlight!, move)); } function ornithopterIntegrity(g: Game) { discoveryFlightIntegrity(g); const flight = g.ornithopter; if (!flight) return; const owner = getPlayer(g, flight.player); requireRule( g.status === 'playing' && g.phase === 5 && g.active === owner.id && flight.turn === g.turn && ['range3', 'twoGroups'].includes(flight.mode) && Number.isSafeInteger(flight.completed) && flight.completed >= 0 && flight.completed < (flight.mode === 'twoGroups' ? 2 : 1) && owner.moved === flight.startingMove + flight.completed && richeseCardDefinition(flight.card)?.card.effect === 'ornithopter' && ![ ...g.players.flatMap((p) => p.hand), ...g.deck, ...g.discard, ...(g.richeseCache ?? []), ].some((c) => c.id === flight.card.id), 'The played Ornithopter custody or movement event is inconsistent; restore the saved movement before continuing.', ); if (flight.mode === 'twoGroups' && flight.completed === 1) { requireRule( flight.cohort, 'The original unmoved Ornithopter cohort is missing; restore the saved movement before continuing.', ); noFieldRule(() => validateCohortSelection( flight.cohort!, owner.forces, owner.elites?.forces ?? {}, {}, {}, ), ); } } function finishOrnithopter( g: Game, source: 'move' | 'end', movement: CompletedMovement | null = null, ) { const flight = g.ornithopter; if (!flight) return; requireRule( !g.pendingNullentropy, 'Finish the paid search before completing the movement card.', ); g.discard.push(flight.card); g.ornithopter = null; log( g, `${getPlayer(g, flight.player).name} finished Ornithopter after ${flight.completed} group move${flight.completed === 1 ? '' : 's'} and discarded the played card.`, { faction: getPlayer(g, flight.player).faction, name: 'Ornithopter' }, ); // completeMove validated arrival with a pure quote before moving any forces. // The completed discard resumes that arrival without speculative effect replay. const resume = { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening, }; g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; const continuation: Extract< NonNullable['continuation'], { kind: 'ornithopterDiscard' } > = { kind: 'ornithopterDiscard', source, flight, movement, resume, stateSignature: '', }; continuation.stateSignature = ornithopterDiscardSignature(g, continuation); stageTreacheryDiscard( g, `ornithopter:${source}`, [{ card: flight.card, discardedBy: flight.player, publicFace: true }], continuation, ); } function ornithopterView(g: Game, p: Player) { const flight = g.ornithopter?.player === p.id ? g.ornithopter : null; const card = flight?.card ?? p.hand.find((c) => richeseCardDefinition(c)?.card.effect === 'ornithopter'); if (!card) return null; const modes = (['range3', 'twoGroups'] as const).map((mode) => ({ mode, blocked: ornithopterBlock(g, p, mode) ?? transferCardBlock(g, p, card), })); return { card, blocked: flight ? null : modes.every((m) => m.blocked) ? modes[0].blocked : null, modes, active: flight ? { event: flight.event, mode: flight.mode, completed: flight.completed, remaining: (flight.mode === 'twoGroups' ? 2 : 1) - flight.completed, cohort: flight.cohort, } : null, }; } function sandmasterOrder(g: Game, p: Player, move: MovementOrder): SandmasterOrder { return { group: move.group, eliteGroup: move.eliteGroup, elite: move.elite, origin: move.origin, to: move.to, sector: move.sector, range: movementOrderRange(g, p, move) }; } function validateSandmasterOrder(g: Game, p: Player, move: MovementOrder) { if (move.sandmaster === undefined) return; requireRule(!move.source && !move.noField && !move.planetologist && !move.ornithopterEvent && !move.discoveryFlight, 'Sandmaster with special movement routes is still being integrated.'); nexusRule(() => validateSandmasterMovement(g, p.id, sandmasterOrder(g, p, move), move.sandmaster!)); } function sandmasterIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null]; }).filter(r => r?.kind === 'fremenMovement'); const move = g.pendingFremenMove?.order; if (move?.sandmaster !== undefined || responses.some(r => r?.sandmasterProof !== undefined)) { requireRule(move && responses.length > 0 && responses.every(r => r!.owner === move.player && r!.sandmasterProof === JSON.stringify(move.sandmaster)), 'The saved Sandmaster route lost its original Fremen response.'); validateMovementOrder(g, getPlayer(g, move.player), move); } for (const other of [g.pendingIxMove, g.pendingChoamMove]) if (other?.sandmaster !== undefined) validateSandmasterOrder(g, getPlayer(g, other.player), other); } function movementOrderRange(g: Game, p: Player, move: MovementOrder, origin = move.origin) { return move.discoveryFlight || move.ornithopterRange ? 3 : planetologistRange( movementRange(g, p, selectedOriginElites(move.group, move.eliteGroup, origin)), move.planetologist?.mode); } function validatePlanetologistMove(g: Game, p: Player, move: Pick) { const skill = move.planetologist; if (!skill) { requireRule(move.origins === undefined, 'Two movement origins require Planetologist.'); return; } requireRule(g.phase === 5 && skill && typeof skill === 'object' && (skill.mode === 'range' || skill.mode === 'gather') && skill.leader === planetologistLeader(g, p.id), 'This Planetologist movement no longer has its living, uncaptured skilled leader.'); requireRule(planetologistMovementModeSupported(g), 'Planetologist movement combined with expansion factions is still being implemented.'); requireRule(!move.noField && !move.ornithopterEvent && !move.discoveryFlight, 'Planetologist combined with special movement cards or No-Field is still being implemented.'); requireRule(skill.mode === 'gather' ? Array.isArray(move.origins) && move.origins.length === 2 && new Set(move.origins).size === 2 && move.origins[0] === move.origin : move.origins === undefined, 'Choose the extra range or exactly two origins, not both.'); } function movementOrigins(move: Pick): string[] { return move.origins ?? [move.origin]; } function movementStance(g: Game, p: Player, origins: string[], to: string, fighters: boolean) { requireRule(!g.advanced || p.faction !== 'beneGesserit' || at(p, to) > 0 || !g.players.some(other => other.id !== p.id && at(other, to)) || origins.every(origin => isAdvisor(p, origin) === isAdvisor(p, origins[0])), 'Gathering advisors and fighters into an enemy-only territory still needs the combined flip response. Choose sources with the same stance or a destination with a determined stance.'); const advisors = arrivalAsAdvisor(g, p, to, origins[0]); const lockedTurn = Math.max(0, ...origins.map(origin => p.advisors?.[origin]?.lockedTurn ?? 0)) || undefined; requireRule(advisors || lockedTurn !== g.turn || !g.players.some(other => other.id !== p.id && at(other, to)), 'New advisors cannot become fighters this turn.'); const wantsFighters = advisors && fighters; requireRule(!wantsFighters || (lockedTurn !== g.turn && !at(p, to) && (!(p.ally && at(getPlayer(g, p.ally), to)) || sharesEcazOccupation(g, p, to))), 'These advisors cannot flip to fighters on arrival.'); return {advisors, lockedTurn, wantsFighters}; } function validateFlightSelection(g: Game, p: Player, move: MovementOrder) { if (move.discoveryFlight) nexusRule(() => validateDiscoveryFlight(g, move.discoveryFlight!, move)); const flight = g.ornithopter; requireRule( !flight || flight.player !== p.id || move.ornithopterEvent === flight.event, 'Use the current Ornithopter movement event.', ); if (!move.ornithopterEvent) return; requireRule( flight?.player === p.id && flight.event === move.ornithopterEvent && move.ornithopterRange === (flight.mode === 'range3'), 'This Ornithopter movement event is stale.', ); if (flight.completed && flight.cohort) noFieldRule(() => validateCohortSelection( flight.cohort!, p.forces, p.elites?.forces ?? {}, Object.fromEntries(move.group), move.eliteGroup, move.noField, ), ); } function movementRange(g: Game, p: Player, elite: number) { const blocked = p.faction === 'ixians' ? p.ixMovementBlocked : p.fremenMovementBlocked; return groundMovementRange({ faction: p.faction, cityOrnithopters: !!(fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')), selectedElites: elite, nativeBlocked: (blocked?.turn === g.turn && blocked.move === p.moved) || (p.faction === 'fremen' && p.fremenNexusMovementBlockedTurn === g.turn), choamBonus: p.faction === 'choam' && g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0, }); } function balisetPrevents(g: Game, player: string, origin: string, to: string) { const choam = byFaction(g, 'choam'); return ( g.phase === 5 && origin !== to && !!choam && at(choam, to) > 0 && !!g.choamBaliset?.some( (b) => b.turn === g.turn && b.player === player && b.territory === to, ) ); } function offerChoamMovement(g: Game, move: MovementOrder) { if (move.source === 'ambassador') validateAmbassadorRelocationArrival(g, move); else validateMovementArrival(g, move); const choam = byFaction(g, 'choam'); if ( g.phase === 5 && choam && move.player !== choam.id && movementOrigins(move).some(origin => origin !== move.to) && at(choam, move.to) > 0 ) { g.pendingChoamMove = move; g.decision = { kind: 'choamMovement', player: choam.id, mover: move.player, territory: move.to, sector: move.sector, amount: move.total, }; } else if (move.source === 'ambassador') commitAmbassadorRelocation(g, move); else completeMove(g, move); } function validateMovementOrder(g: Game, p: Player, move: MovementOrder) { if (move.noField) { const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, p.id)); requireRule(!blocked, blocked ?? 'The No-Field cannot move.'); } if (move.source === 'ambassador') { const { beneficiary } = currentFremenAmbassador(g, move.ambassadorEvent); requireRule( beneficiary.id === p.id && move.player === p.id && move.ambassadorEvent, 'The Ambassador move belongs to another beneficiary.', ); const current = quoteAmbassadorRelocation(g, { type: 'decision', event: move.ambassadorEvent, forces: Object.fromEntries(move.group), eliteForces: move.eliteGroup, territory: move.to, sector: move.sector, fighters: move.wantsFighters, ...(move.noField ? { noField: { tokenId: move.noField.tokenId, event: move.noField.event, }, } : {}), }); const { source: _source, ambassadorEvent: _event, ...declared } = move; requireRule( JSON.stringify(current) === JSON.stringify(declared), 'The declared Ambassador relocation no longer matches its current forces and stance.', ); return; } requireRule( g.phase === 5 && g.active === p.id && p.moved < movesAllowed(g, p), 'The movement is no longer available.', ); validateFlightSelection(g, p, move); validatePlanetologistMove(g, p, move); validateSandmasterOrder(g, p, move); const selected = forceGroup(g, p, { type: 'move', forces: Object.fromEntries(move.group), eliteForces: move.eliteGroup, ...(move.noField ? { noField: move.noField.tokenId, event: move.noField.event } : {}), }, move.planetologist?.mode === 'gather' ? 2 : 1); requireRule( selected.origin === move.origin && selected.total === move.total && selected.elite === move.elite && selected.group.length === move.group.length && JSON.stringify(selected.origins) === JSON.stringify(movementOrigins(move)), 'The declared force group no longer matches its movement.', ); if (move.planetologist) { const stance = movementStance(g, p, selected.origins, move.to, move.wantsFighters); requireRule(stance.advisors === move.advisors && stance.wantsFighters === move.wantsFighters && stance.lockedTurn === move.lockedTurn, 'The declared Planetologist arrival stance is no longer current.'); } requireRule( movementOrigins(move).every(origin => !balisetPrevents(g, p.id, origin, move.to)), 'Baliset prevents that movement.', ); allowedEntry( g, p, move.to, move.sector, false, move.advisors && !move.wantsFighters, ); requireRule( [ ...move.group.map(([key]) => key), ...(move.noField ? [move.noField.from] : []), ].every( (key) => gameDistance(g, key, location(move.to, move.sector), (k) => pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)), ) <= movementOrderRange(g, p, move, splitLocation(key).territory), ), 'The movement route is no longer available.', ); } function pendingFremenMovement(g: Game, response: ResponseWindow) { const pending = g.pendingFremenMove; requireRule( pending && response.kind === 'fremenMovement' && pending.order.player === response.owner && pending.turn === g.turn, 'This Fremen movement response is no longer current.', ); const p = getPlayer(g, pending.order.player); requireRule( p.faction === 'fremen' && pending.move === p.moved && response.location === location(pending.order.to, pending.order.sector) && response.amount === pending.order.total, 'This Fremen movement declaration is no longer current.', ); validateMovementOrder(g, p, pending.order); return pending; } function resumeChoamMovement(g: Game, retryUnsupportedArrival = false) { const move = g.pendingChoamMove!; if (move.source === 'ambassador') currentFremenAmbassador(g, move.ambassadorEvent); g.pendingChoamMove = null; const p = getPlayer(g, move.player); try { validateMovementOrder(g, p, move); } catch (error) { if (!(error instanceof RuleError)) throw error; log( g, `${p.name}'s declared movement is no longer legal. No movement was spent.`, ); if (move.source === 'ambassador') offerAmbassadorRelocation(g); return; } if (retryUnsupportedArrival) { try { if (move.source === 'ambassador') { const blocked = ambassadorRelocationArrivalBlock(g, move.player, move.origin, move.to, move.total); if (blocked) throw new MovementArrivalError(blocked); } else quoteMovementArrival(g, move); } catch (error) { if (!(error instanceof MovementArrivalError)) throw error; log(g, `${p.name}'s uncommitted movement returned for another choice. ${error.message} No additional forces, spice, cards or movement allowance were spent.`); if (move.source === 'ambassador') offerAmbassadorRelocation(g); return; } } if (move.source === 'ambassador') commitAmbassadorRelocation(g, move); else completeMove(g, move); } function quoteMovementArrival( g: Game, move: MovementOrder, cancelingChoam = false, ) { return quoteCompletedMovementArrival({ advanced: g.advanced, overlapSupported: arrivalOverlapModeSupported(g), players: g.players, order: move, ambassadors: g.ecazAmbassadors?.tokens ?? [], terror: homeworldTerrorEntryBlock(g, move.total) ? [] : (g.moritaniTerror?.tokens ?? []), flight: g.ornithopter, controls: { response: !cancelingChoam && !!g.response, decision: !!g.decision, pendingTerror: !!g.pendingTerrorEntry, pendingAmbassador: !!g.pendingAmbassador, paidBox: !!g.pendingNullentropy, }, }); } function validateMovementArrival(g: Game, move: MovementOrder, cancelingChoam = false) { try { return quoteMovementArrival(g, move, cancelingChoam); } catch (error) { if (error instanceof MovementArrivalError) throw new RuleError(error.message); throw error; } } function quoteShipmentArrival( g: Game, shipment: PendingShipment, ignoreGuildDecision = false, ) { // Guild Ambassador arrivals have a staged continuation which deliberately // owns pendingAmbassador throughout shipment, Terror and accompaniment. // Its source-specific validator handles those gates in their settled order. if (shipment.source === 'ambassador') return; const p = getPlayer(g, shipment.player); const amount = shipment.alliedNoField ? Math.max(1, shipment.amount) : shipment.amount + (shipment.smugglerCompanion ? 1 : 0); const guild = byFaction(g, 'guild'); const guildPayment = guildShipmentIncome({ guild: guild?.id, shipper: p.id, ally: p.ally, cost: shipment.cost, allyPayment: shipment.allyPayment, bankOnly: g.karamaShipping?.player === p.id, }); const bg = byFaction(g, 'beneGesserit'); const advisorFollowup = !!( p.faction !== 'fremen' && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0 ); const currentDecision = !!( g.decision && !( ignoreGuildDecision && g.decision.kind === 'guildShipment' && g.decision.player === guild?.id && g.decision.shipper === shipment.player && g.decision.territory === shipment.territory && g.decision.sector === shipment.sector ) ); return quoteCompletedMovementArrival({ advanced: g.advanced, overlapSupported: arrivalOverlapModeSupported(g), players: g.players, order: { player: p.id, origin: 'reserves', to: shipment.territory, advisors: shipment.advisors, wantsFighters: false, }, ambassadors: g.ecazAmbassadors?.tokens ?? [], terror: homeworldTerrorEntryBlock(g, amount) ? [] : (g.moritaniTerror?.tokens ?? []), controls: { response: !!g.response || guildPayment > 0 || (g.advanced && p.faction === 'fremen' && shipment.sector === g.storm), decision: currentDecision || advisorFollowup, pendingTerror: !!g.pendingTerrorEntry, pendingAmbassador: !!g.pendingAmbassador, paidBox: !!g.pendingNullentropy, }, flight: null, }); } function validateShipmentArrival(g: Game, shipment: PendingShipment) { try { return quoteShipmentArrival(g, shipment); } catch (error) { if (error instanceof MovementArrivalError) throw new RuleError(error.message); throw error; } } function recoverableUnsupportedShipment(shipment: PendingShipment) { return ( shipment.source === undefined && shipment.ambassadorEvent === undefined && shipment.noField === undefined && shipment.noFieldSkillProof === undefined && shipment.alliedNoField === undefined && shipment.smuggler === undefined && shipment.smugglerCompanion === undefined && shipment.nexusEvent === undefined && shipment.guildNexusEvent === undefined && shipment.guildSecretEvent === undefined ); } function completeMove(g: Game, move: MovementOrder) { if (move.noField) { const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, move.player)); requireRule(!blocked, blocked ?? 'The No-Field cannot move.'); } const { group, eliteGroup, elite, origin, total: n, to, sector: s, advisors, wantsFighters, lockedTurn, player: id, } = move; const p = getPlayer(g, id); validatePlanetologistMove(g, p, move); validateFlightSelection(g, p, move); validateMovementArrival(g, move); validateSandmasterOrder(g, p, move); const flight = g.ornithopter; if ( flight?.player === id && flight.mode === 'twoGroups' && flight.completed === 0 ) { const marker = p.noField?.deployed; flight.cohort = noFieldRule(() => createRemainingCohort( p.forces, p.elites?.forces ?? {}, Object.fromEntries(group), eliteGroup, marker && p.noFieldEvent ? { tokenId: marker.tokenId, event: p.noFieldEvent, from: location(marker.location.territory, marker.location.sector), } : undefined, move.noField, ), ); } if (move.noField) { requireRule( p.noField && p.noFieldEvent === move.noField.event, 'This No-Field movement is no longer current.', ); p.noField = noFieldRule(() => moveRicheseNoField(p.noField!, move.noField!.tokenId, { territory: to, sector: s, }), ); p.noFieldEvent = crypto.randomUUID(); } for (const pile of move.sandmaster?.piles ?? []) { g.spice[pile.key]--; if (!g.spice[pile.key]) delete g.spice[pile.key]; p.spice++; log(g, `${p.name}'s Sandmaster collected 1 spice while moving into or through ${territory(splitLocation(pile.key).territory).name}. This territory grants at most one collection during this movement.`, { faction: p.faction, name: 'Sandmaster collection' }); } removeGroup(p, group, eliteGroup); if (n > (move.noField ? 1 : 0)) place(p, to, s, n - (move.noField ? 1 : 0), elite); if (advisors) { const arrivalLock = Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0); (p.advisors ??= {})[to] = arrivalLock ? { lockedTurn: arrivalLock } : {}; } else if (p.advisors) delete p.advisors[to]; if (wantsFighters) g.response = { kind: 'advisorFlip', owner: id, location: to, advisors: false, passed: [], }; finishShipmentPromises(g, p, null); p.shipped = true; p.moved++; if (move.discoveryFlight) { g.discoveries = nexusRule(() => completeDiscoveryFlight(g, move.discoveryFlight!, move)); log(g, `${p.name} used the carried Discovery Ornithopter for this movement and removed its token.`); } const cunning = currentGuildCunning(g,id); if (cunning) { requireRule(cunning.stage === 'extraMove', 'Guild Cunning permits no movement before the second shipment.'); cunning.movesAfter++; saveGuildCunning(g,cunning); } const completed: CompletedMovement = { player: id, origin, ...(move.origins ? {origins: move.origins} : {}), total: n, to, sector: s, elite, noField: !!move.noField, }; if (flight?.player === id) { flight.completed++; if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) { finishOrnithopter(g, 'move', completed); return; } } finishMovedGroup(g, completed); } /** Forces and movement counters are already committed; only arrival remains. */ function finishMovedGroup(g: Game, move: CompletedMovement) { const { player: id, origin, total: n, to, sector: s, elite } = move; const p = getPlayer(g, id); g.karamaShipping = null; log( g, move.noField ? `${p.name} moved a concealed No-Field${n > 1 ? ` with ${n - 1} physical forces` : ''} from ${territory(origin).name} to ${territory(to).name}. Its value remains concealed.` : `${p.name} moved ${n} forces from ${movementOrigins(move).map(source => territory(source).name).join(' and ')} to ${territory(to).name}.`, { faction: p.faction, name: 'Movement' }, ); intrusion(g, p, to); if (movementOrigins(move).some(source => source !== to)) openTerritoryEntry(g, p, to, s, n, elite, 'movement'); } /** A played movement card is already retired before queue advancement. */ function finishMovementTurn(g: Game, id: string) { const p = getPlayer(g, id); const cunning = currentGuildCunning(g,id); if (cunning) { requireRule(cunning.stage !== 'pending' && cunning.shipment?.stage !== 'pending', 'Finish the Guild Cunning response or shipment first.'); cunning.stage = 'completed'; saveGuildCunning(g,cunning); } g.karamaShipping = null; g.movementRemaining ??= g.order.slice(g.order.indexOf(id)); const shared = p.ally ? gameTerritories(g).filter( (terr) => terr.type !== 'polar' && at(p, terr.id) && at(getPlayer(g, p.ally!), terr.id) && sharesEcazOccupation(g, p, terr.id), ) : []; if (shared.length) log( g, `${p.name} and ${getPlayer(g, p.ally!).name} remain together in ${shared.map((t) => t.name).join(', ')} under Ecaz Occupy. No allied separation losses apply.`, { faction: 'ecaz', name: 'Occupy' }, ); if (p.ally) for (const terr of gameTerritories(g)) if ( terr.type !== 'polar' && at(p, terr.id) && at(getPlayer(g, p.ally), terr.id) && !sharesEcazOccupation(g, p, terr.id) && !g.movementRemaining.includes(p.ally) && !( p.allySinceTurn === g.turn && getPlayer(g, p.ally).allySinceTurn === g.turn ) ) killTerritory(g, p, terr.id); g.movementRemaining = g.movementRemaining.filter((other) => other !== id); if (g.saphoMovementLast?.player === id) g.saphoMovementLast = null; movementTurn(g); } const implementedAmbassadorEffects: readonly AmbassadorEffect[] = [ 'ecaz', 'atreides', 'harkonnen', 'emperor', 'choam', 'ixians', 'beneGesserit', 'richese', 'fremen', 'guild', ]; function returnAmbassadorsIn(g: Game, to: string, reason: string) { for (const token of g.ecazAmbassadors?.tokens.filter( (t) => t.zone === 'placed' && t.location === to, ) ?? []) { g.ecazAmbassadors = destroyAmbassador(g.ecazAmbassadors!, token.id); log( g, `The ${faction(token.effect).name} Ambassador returned from ${territory(to).name} to Ecaz’s supply because ${reason}. This was destruction, not a triggered effect, and does not advance the five-token cycle.`, ); } } function ambassadorDiscardBlock(g: Game, p: Player, card: Card, effect?: AmbassadorEffect) { if (g.pendingWinnerDiscards?.player === p.id && [...g.pendingWinnerDiscards.cards, ...g.pendingWinnerDiscards.optional].includes(card.id)) return 'This played card is reserved until the winner’s casualties and card cleanup.'; if (g.battle?.lateDefense?.[p.id] === card.id) return 'This Portable Snooper is already played and reserved for battle cleanup.'; if (giftReserved(g, p.id, card.id)) return 'This card is reserved for the pending Richese gift.'; if ( g.truthtrance?.queue.some( (entry) => entry.player === p.id && entry.card === card.id, ) ) return 'This card is already committed to Truthtrance.'; if (retentionReservesCard(g, p.id, card.id)) return 'This card is reserved for Moritani battle cleanup.'; const plan = g.battle?.plans[p.id]; if (plan && [plan.weapon, plan.defense, plan.leader].includes(card.id)) return 'This card is committed to a sealed battle plan.'; if (g.battle && committedPlanElements(g.battle, p.id).some(element => element.value === card.id)) return 'This card is committed to prescience.'; if (effect === 'choam') return homeworldRule(() => homeworldWorthlessSaleBlock(g, p.id, card)); return null; } function ambassadorEffectBlock( g: Game, effect: AmbassadorEffect, beneficiary: Player, entrant: Player, ): string | null { if (g.pendingArrivalOverlap && (effect === 'fremen' || effect === 'guild')) return 'Finish the overlapping Terror before another Ambassador-created arrival; decline this effect.'; if (!implementedAmbassadorEffects.includes(effect)) return 'This Ambassador effect is still being implemented.'; if (effect === 'ecaz') { const owner = byFaction(g, 'ecaz'); if (!owner || beneficiary.id !== owner.id) return 'Assigning the Ecaz Ambassador effect to an ally is still being implemented.'; if (g.players.some((p) => p.leaders.some((l) => l.id === DUKE_VIDAL_ID))) return 'Duke Vidal is unavailable for this acquisition.'; return ecazDukeAcquisitionBlock(g, owner.id); } if ( effect === 'ixians' && !beneficiary.hand.some( (card) => !ambassadorDiscardBlock(g, beneficiary, card), ) ) return 'The beneficiary needs a card they can discard.'; if ( effect === 'harkonnen' && !(entrant.faction === 'tleilaxu' ? entrant.faceDancers?.length : entrant.traitors.length) ) return 'The entrant has no held Traitor Card to inspect.'; return null; } function arrivalOverlapModeSupported(g: Game): boolean { return !!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') && g.players.every(player => CLASSIC_FACTIONS[player.faction] === true || player.faction === 'ecaz' || player.faction === 'moritani') && g.expansions.every(expansion => expansion === 'ecaz') && !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && !g.ecazTreachery; } function arrivalOverlapIntegrity(g: Game) { const record = g.pendingArrivalOverlap; if (!record) return; const ecazIndex = g.order.indexOf(record.ecaz); const moritaniIndex = g.order.indexOf(record.moritani); const continuation = g.pendingTreacheryDiscard?.continuation; const ambassador = g.pendingAmbassador ?? (continuation?.kind === 'ambassador' ? continuation.entry : null); const terror = g.pendingTerrorEntry ?? (continuation?.kind === 'terrorDiscard' ? continuation.entry : null); const active = record.stage === 'first' ? record.first : record.first === 'ambassador' ? 'terror' : 'ambassador'; requireRule(g.status === 'playing' && arrivalOverlapModeSupported(g) && record.turn === g.turn && record.phase === g.phase && typeof record.event === 'string' && record.event.length > 0 && typeof record.signature === 'string' && record.signature === arrivalOverlapSignature(record) && g.players.some(p => p.id === record.ecaz && p.faction === 'ecaz') && g.players.some(p => p.id === record.moritani && p.faction === 'moritani') && g.players.some(p => p.id === record.entrant && p.id !== record.ecaz && p.id !== record.moritani) && ecazIndex >= 0 && moritaniIndex >= 0 && ecazIndex !== moritaniIndex && g.order.lastIndexOf(record.ecaz) === ecazIndex && g.order.lastIndexOf(record.moritani) === moritaniIndex && record.first === (ecazIndex < moritaniIndex ? 'ambassador' : 'terror') && (record.stage === 'first' || record.stage === 'second') && Number.isSafeInteger(record.sector) && TERROR_STRONGHOLDS.includes(record.territory) && territory(record.territory).sectors.includes(record.sector) && Number.isSafeInteger(record.amount) && record.amount >= 0 && Number.isSafeInteger(record.elite) && record.elite >= 0 && record.elite <= record.amount && (record.resume === 'none' || record.resume === 'wormRide') && typeof record.ambassadorToken === 'string' && record.ambassadorToken.length > 0 && Array.isArray(record.terrorTokens) && record.terrorTokens.length > 0 && record.terrorTokens.every(id => typeof id === 'string' && id.length > 0) && new Set(record.terrorTokens).size === record.terrorTokens.length && (active === 'ambassador' ? !!ambassador && !terror && ambassador.event === record.ambassadorEvent && ambassador.owner === record.ecaz && ambassador.entrant === record.entrant && ambassador.token === record.ambassadorToken && ambassador.territory === record.territory && ambassador.turn === record.turn && ambassador.phase === record.phase : !!terror && !ambassador && terror.entrant === record.entrant && terror.territory === record.territory && terror.turn === record.turn && terror.phase === record.phase && terror.cause === record.cause && record.terrorTokens.includes(terror.token)), 'The saved Ambassador/Terror overlap lost its original ordered arrival.'); } function openAmbassadorEntry( g: Game, owner: Player, token: AmbassadorToken, entrant: Player, to: string, sector: number, resume: 'none' | 'wormRide', ): boolean { g.pendingAmbassador = { event: `ambassador:${g.turn}:${g.phase}:${(g.log.at(-1)?.seq ?? 0) + 1}`, owner: owner.id, entrant: entrant.id, token: token.id, territory: to, sector, turn: g.turn, phase: g.phase, stage: 'offer', resume, ...(resume === 'wormRide' ? { wormRider: entrant.id } : {}), copyChoices: token.effect === 'beneGesserit' ? copiedAmbassadorEffects(g.ecazAmbassadors!) : [], }; g.decision = { kind: 'ecazAmbassador', player: owner.id }; log(g, `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`); return true; } function finishArrivalOverlap(g: Game, overlap: NonNullable) { requireRule(!g.pendingAmbassador && !g.pendingTerrorEntry, 'Both reactions must finish before resuming the original arrival.'); g.pendingArrivalOverlap = null; log(g, 'Both optional Ambassador and Terror reactions to the original entry have finished.'); if (overlap.resume === 'wormRide') nextWormRide(g); } /** The original entry is committed once; eligible overlapping owners act in storm order. */ function openTerritoryEntry( g: Game, entrant: Player, to: string, sector: number, amount: number, elite: number, cause: NonNullable['cause'], resume: 'none' | 'wormRide' = 'none', ) { const owner = byFaction(g, 'ecaz'); const token = g.ecazAmbassadors?.tokens.find( (t) => t.zone === 'placed' && t.location === to, ); if ( !owner || !token || !canTriggerAmbassador({ owner: owner.id, ally: owner.ally, entrant: entrant.id, entrantFaction: entrant.faction, advisors: isAdvisor(entrant, to), effect: token.effect, }) ) return openTerrorEntry( g, entrant, to, sector, amount, elite, cause, resume, ); const moritani = byFaction(g, 'moritani'); const terrorTokens = moritani && entrant.id !== moritani.id && entrant.id !== moritani.ally && !homeworldTerrorEntryBlock(g, amount) ? g.moritaniTerror?.tokens.filter(t => t.status === 'placed' && t.location === to) ?? [] : []; requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador, 'Finish the other arrival reaction before opening this entry.'); if (!terrorTokens.length) return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume); requireRule(arrivalOverlapModeSupported(g) && !g.pendingArrivalOverlap && moritani && g.order.includes(owner.id) && g.order.includes(moritani.id), 'Ambassador and Terror overlap still needs a supported classic Ecaz/Moritani table.'); const first = g.order.indexOf(owner.id) < g.order.indexOf(moritani.id) ? 'ambassador' : 'terror'; const overlap: NonNullable = { event: crypto.randomUUID(), signature: '', turn: g.turn, phase: g.phase, entrant: entrant.id, territory: to, sector, amount, elite, cause, resume, ecaz: owner.id, moritani: moritani.id, first, stage: 'first', ambassadorToken: token.id, terrorTokens: terrorTokens.map(t => t.id), }; overlap.signature = arrivalOverlapSignature(overlap); g.pendingArrivalOverlap = overlap; log(g, `${owner.name} and ${moritani.name} both have optional reactions to ${entrant.name}'s entry in ${territory(to).name}; ${first === 'ambassador' ? owner.name : moritani.name} acts first in storm order.`); if (first === 'terror') return openTerrorEntry(g, entrant, to, sector, amount, elite, cause, 'none'); openAmbassadorEntry(g, owner, token, entrant, to, sector, 'none'); overlap.ambassadorEvent = g.pendingAmbassador!.event; return true; } function finishAmbassador(g: Game, deferResume = false) { const pending = g.pendingAmbassador!; if (pending.revivalEvent !== undefined || pending.victoryEvent !== undefined) { const arrival = pending.revivalEvent !== undefined ? g.homeworldRevivalReturn : g.homeworldVictoryReinforcement; requireRule(arrival && ambassadorPhaseAllowed(g), 'This Ambassador has lost its original Homeworld arrival completion.'); completeHomeworldArrivalAmbassador(arrival, pending.event); } if ( pending.beneficiary && pending.effect !== 'ecaz' && g.ecazAmbassadors!.cohort.every((id) => g.ecazAmbassadors!.tokens.some( (t) => t.id === id && (t.zone === 'used' || t.zone === 'removed'), ), ) ) { g.ecazAmbassadors = replenishAmbassadors(g.ecazAmbassadors!, random); log( g, 'All five random Ambassadors have been triggered. Ecaz drew a new supply of five; the removed Bene Gesserit token stays out of the game.', ); } g.pendingAmbassador = null; const overlap = g.pendingArrivalOverlap; if (overlap?.ambassadorEvent === pending.event) { requireRule(pending.entrant === overlap.entrant && pending.territory === overlap.territory && pending.turn === overlap.turn && pending.phase === overlap.phase && pending.resume === 'none', 'The Ambassador has lost its original overlapping arrival.'); if (overlap.stage === 'first' && overlap.first === 'ambassador') { overlap.stage = 'second'; requireRule(openTerrorEntry(g, getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, overlap.amount, overlap.elite, overlap.cause, 'none'), 'The originally triggered Terror token is no longer in the stronghold.'); return; } requireRule(overlap.stage === 'second' && overlap.first === 'terror', 'The second Ambassador reaction is out of order.'); finishArrivalOverlap(g, overlap); return; } if (!deferResume && pending.resume === 'wormRide') nextWormRide(g); } function currentFremenAmbassador(g: Game, event?: string, completed = false) { const entry = g.pendingAmbassador; const owner = entry && g.players.find((p) => p.id === entry.owner); const beneficiary = entry && g.players.find((p) => p.id === entry.beneficiary); const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry?.token); requireRule( entry && owner?.faction === 'ecaz' && beneficiary && entry.effect === 'fremen' && entry.turn === g.turn && entry.phase === g.phase && ambassadorPhaseAllowed(g) && (event === undefined || entry.event === event) && (completed ? entry.stage === 'arrival' && entry.relocation : entry.stage === 'move') && (completed || beneficiary.id === owner.id || (owner.ally === beneficiary.id && beneficiary.ally === owner.id)) && token && ((token.effect === 'fremen' && token.zone === 'used') || (token.effect === 'beneGesserit' && token.zone === 'removed' && entry.copyChoices.includes('fremen'))), 'This Fremen Ambassador relocation is no longer current.', ); return { entry, beneficiary }; } /** Check historical arrival receipts without replaying movement or inspecting concealed values. */ function ambassadorRelocationIntegrity(g: Game) { const entry = g.pendingAmbassador; if (entry && (entry.revivalEvent !== undefined || entry.victoryEvent !== undefined || g.homeworldRevivalReturn?.ambassadors?.some((record) => record.event === entry.event) || g.homeworldVictoryReinforcement?.ambassadors?.some((record) => record.event === entry.event))) requireRule(ambassadorPhaseAllowed(g), 'This Ambassador has lost its original Homeworld revival arrival.'); if (entry) requireRule( validAmbassadorResume(g, entry), 'This Ambassador has lost its original arrival continuation.', ); if (g.pendingShipment?.source === 'ambassador') requireRule( entry?.effect === 'guild' && entry.stage === 'ship', 'This pending shipment has no matching Guild Ambassador.', ); const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [ g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null, ]; const responses = contexts.flatMap((context) => context?.response ? [context.response] : [], ); for (const { pending } of savedKaramaContexts(g)) if (pending.use.kind === 'cancel') responses.push(pending.use.response); const decisions = contexts.flatMap((context) => context?.decision ? [context.decision] : [], ); const terror = g.pendingTerrorEntry ?? (continuation?.kind === 'terrorDiscard' ? continuation.entry : null); const relatedResponses = responses.filter( (response) => response.advisorResume === 'ambassador' || response.advisorAmbassadorEvent !== undefined, ); const relatedDecisions = decisions.filter( (decision) => decision.kind === 'intrusion' && decision.ambassadorEvent !== undefined, ); const advisorDecisions = decisions.filter( (decision) => decision.kind === 'advisor' && decision.ambassadorEvent !== undefined, ); const relatedTerror = terror?.cause === 'ambassador' || terror?.resume === 'ambassador' || terror?.ambassadorEvent !== undefined; if (entry?.effect === 'guild' && entry.stage === 'arrival') { const next = entry.shipmentReceipt?.next; requireRule( next === 'terror' || next === 'accompany' || next === 'advisor' || next === 'finish', 'The saved Guild Ambassador arrival has an invalid continuation stage.', ); const { order } = guildAmbassadorArrival(g, entry.event, next); for (const response of relatedResponses) { requireRule( response.advisorResume === 'ambassador' && response.advisorAmbassadorEvent === entry.event, 'The saved advisor reaction has a different Guild Ambassador parent.', ); try { if (response.kind === 'advisorFlip') quoteMovementCancellation(g, response); else if (response.kind === 'advisor') validateTerminalCancellation(g, response); else throw new RuleError( 'The saved Guild Ambassador advisor response is invalid.', ); } catch (error) { if ( error instanceof MovementCancellationError || error instanceof TerminalCancellationError ) throw new RuleError(error.message); throw error; } } for (const decision of relatedDecisions) requireRule( decision.kind === 'intrusion' && next === 'terror' && decision.ambassadorEvent === entry.event && decision.territory === order.territory && getPlayer(g, decision.player).faction === 'beneGesserit' && decision.player !== order.player && !decision.followup && !decision.wormRide, 'This saved Intrusion does not match its Guild Ambassador arrival.', ); for (const decision of advisorDecisions) requireRule( decision.kind === 'advisor' && next === 'advisor' && decision.ambassadorEvent === entry.event && decision.shipment === order.player && decision.destination === location(order.territory, order.sector) && getPlayer(g, decision.player).faction === 'beneGesserit' && decision.player !== order.player && getPlayer(g, order.player).faction !== 'fremen', 'This saved accompaniment does not match its Guild Ambassador shipment.', ); if (relatedTerror) requireRule( terror && ambassadorTerrorEntryMatches(g, terror), 'This saved Terror does not match its Guild Ambassador arrival.', ); requireRule( next === 'accompany' || next === 'finish' ? relatedTerror : relatedResponses.length + relatedDecisions.length + advisorDecisions.length > 0, 'The saved Guild Ambassador shipment has lost its pending arrival reaction.', ); return; } if (entry?.stage !== 'arrival') { requireRule( !relatedResponses.length && !relatedDecisions.length && !advisorDecisions.length && !relatedTerror, 'This saved arrival reaction has no completed Ambassador relocation.', ); if (entry?.stage === 'move') currentFremenAmbassador(g); if (entry?.stage === 'ship') { const { beneficiary } = currentGuildAmbassador(g); const selections = decisions.filter( (decision) => decision.kind === 'ecazAmbassador', ); const stops = decisions.filter( (decision) => decision.kind === 'guildShipment', ); if (g.pendingShipment) { requireRule( g.pendingShipment.source === 'ambassador' && g.phase === 5 && !selections.length && stops.length > 0, 'The pending Guild Ambassador shipment has lost its shipment decision.', ); for (const stop of stops) validateGuildShipmentDecision(g, stop); } else requireRule( !stops.length && selections.length > 0 && selections.every((decision) => decision.player === beneficiary.id), 'The Guild Ambassador has lost its beneficiary shipment selection.', ); } return; } requireRule( !advisorDecisions.length, 'This Ambassador relocation cannot own an accompaniment decision.', ); const next = entry.relocation?.next; requireRule( next === 'intrusion' || next === 'terror' || next === 'finish', 'The saved Ambassador arrival has an invalid continuation stage.', ); try { const order = validateAmbassadorRelocationContext(g, entry.event, next); for (const response of relatedResponses) { requireRule( response.kind === 'advisorFlip' && response.advisorResume === 'ambassador', 'This saved Ambassador advisor reaction is invalid.', ); quoteMovementCancellation(g, response); } for (const decision of relatedDecisions) { requireRule( decision.kind === 'intrusion' && next === 'terror' && decision.ambassadorEvent === entry.event && decision.territory === order.to && order.origin !== order.to && decision.player !== order.player && getPlayer(g, decision.player).faction === 'beneGesserit' && !decision.wormRide && !decision.followup, 'This saved Intrusion does not match its Ambassador arrival.', ); } if (relatedTerror) requireRule( terror && next === 'finish' && terror.cause === 'ambassador' && terror.resume === 'ambassador' && terror.ambassadorEvent === entry.event && terror.entrant === order.player && terror.turn === g.turn && terror.phase === g.phase && terror.territory === order.to && terror.sector === order.sector && terror.amount === order.total && terror.elite === order.elite && order.origin !== order.to, 'This saved Terror does not match its Ambassador arrival.', ); requireRule( next === 'finish' ? relatedTerror : relatedResponses.length + relatedDecisions.length > 0, 'The saved Ambassador arrival has lost its pending reaction.', ); } catch (error) { if (error instanceof MovementCancellationError) throw new RuleError(error.message); throw error; } } function guildAmbassadorArrival( g: Game, event: string, next: NonNullable< NonNullable['shipmentReceipt'] >['next'], ) { try { return validateGuildAmbassadorArrivalContext(g, event, next); } catch (error) { if (error instanceof GuildAmbassadorContinuationError) throw new RuleError(error.message); throw error; } } function ambassadorTerrorEntryMatches( g: Game, child: NonNullable, ) { const parent = g.pendingAmbassador; if ( !parent || parent.stage !== 'arrival' || child.resume !== 'ambassador' || child.cause !== 'ambassador' || child.ambassadorEvent !== parent.event || child.turn !== g.turn || child.phase !== g.phase ) return false; if (parent.effect === 'guild') { const receipt = parent.shipmentReceipt; const order = receipt?.next === 'accompany' ? receipt.order : receipt?.next === 'finish' ? receipt.advisorArrival : null; return ( !!order && child.entrant === order.player && child.territory === order.territory && child.sector === order.sector && child.amount === order.amount && child.elite === order.elite ); } const receipt = parent.relocation, order = receipt?.order; return ( parent.effect === 'fremen' && receipt?.next === 'finish' && !!order && child.entrant === order.player && child.territory === order.to && child.sector === order.sector && child.amount === order.total && child.elite === order.elite ); } function currentGuildAmbassador(g: Game, event?: string) { const entry = g.pendingAmbassador; const owner = entry && g.players.find((p) => p.id === entry.owner); const beneficiary = entry && g.players.find((p) => p.id === entry.beneficiary); const token = g.ecazAmbassadors?.tokens.find( (token) => token.id === entry?.token, ); const entrant = entry && g.players.find((p) => p.id === entry.entrant); requireRule(g.ecazAmbassadors, 'This shipment has no Ambassador inventory.'); try { validateAmbassadors(g.ecazAmbassadors); } catch { throw new RuleError('This shipment has invalid Ambassador custody.'); } requireRule( g.status === 'playing' && entry && owner?.faction === 'ecaz' && beneficiary && entrant && entrant.id !== owner.id && entrant.id !== beneficiary.id && validGameLocation(g, entry.territory, entry.sector) && territory(entry.territory).type === 'stronghold' && validAmbassadorResume(g, entry) && entry.relocation === undefined && entry.shipmentReceipt === undefined && entry.purchaseReceipt === undefined && token?.effect !== entrant.faction && entry.effect === 'guild' && entry.stage === 'ship' && entry.turn === g.turn && entry.phase === g.phase && ambassadorPhaseAllowed(g) && (event === undefined || event === entry.event) && (beneficiary.id === owner.id || (owner.ally === beneficiary.id && beneficiary.ally === owner.id)) && token && ((token.effect === 'guild' && token.zone === 'used') || (token.effect === 'beneGesserit' && token.zone === 'removed' && entry.copyChoices.includes('guild') && !g.ecazAmbassadors.cohort.some((id) => g.ecazAmbassadors!.tokens.some( (t) => t.id === id && t.effect === 'guild', ), ))), 'This Guild Ambassador shipment is no longer current.', ); return { entry, beneficiary }; } function guildAmbassadorRule(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof GuildAmbassadorShipmentError) throw new RuleError(error.message); throw error; } } function quoteAmbassadorShipment(g: Game, action: Action) { const { beneficiary } = currentGuildAmbassador(g, stringField(action.event)); return guildAmbassadorRule(() => quoteGuildAmbassadorShipment(g, beneficiary.id, action), ); } function offerAmbassadorShipment(g: Game) { const entry = g.pendingAmbassador!; entry.stage = 'ship'; const { beneficiary } = currentGuildAmbassador(g); const choices = guildAmbassadorRule(() => guildAmbassadorShipments(g, beneficiary.id), ); if (choices.maximum && choices.destinations.length) g.decision = { kind: 'ecazAmbassador', player: beneficiary.id }; else { log( g, `${beneficiary.name} has no legal reserve shipment through the Guild Ambassador. No forces or ordinary shipment allowance changed; the trigger remains committed.`, ); finishAmbassador(g); } } function validateAmbassadorShipmentArrival( g: Game, order: GuildAmbassadorShipment, ) { const blocked = ambassadorRelocationArrivalBlock( g, order.player, 'reserves', order.territory, order.amount, ); requireRule(!blocked, blocked ?? 'This shipment arrival is unavailable.'); } function validateAmbassadorShipmentOrder(g: Game, shipment: PendingShipment) { const { entry, beneficiary } = currentGuildAmbassador( g, shipment.ambassadorEvent, ); requireRule( shipment.source === 'ambassador' && shipment.ambassadorEvent === entry.event && shipment.turn === g.turn && shipment.player === beneficiary.id && !shipment.noField && !shipment.alliedNoField, 'This declaration does not match its independent Guild Ambassador shipment.', ); const order = quoteAmbassadorShipment(g, { type: 'decision', event: entry.event, amount: shipment.amount, elite: shipment.elite, territory: shipment.territory, sector: shipment.sector, }); requireRule( shipment.cost === 0 && shipment.allyPayment === 0 && shipment.advisors === order.advisors, 'The free shipment declaration has an invalid price or arrival stance.', ); validateAmbassadorShipmentArrival(g, order); return order; } function commitAmbassadorShipment(g: Game, shipment: PendingShipment) { const order = validateAmbassadorShipmentOrder(g, shipment); const { entry, beneficiary: p } = currentGuildAmbassador( g, shipment.ambassadorEvent, ); p.reserves -= order.amount; if (p.elites) p.elites.reserves -= order.elite; place(p, order.territory, order.sector, order.amount, order.elite); observeOccupation(g); if (order.advisors) (p.advisors ??= {})[order.territory] ??= {}; if (p.faction !== 'fremen') techIncome(g, 'heighliners', p); entry.stage = 'arrival'; entry.shipmentReceipt = { order, next: 'intrusion' }; log( g, `${p.name} shipped ${order.amount} physical forces from reserves to ${territory(order.territory).name}, sector ${order.sector}, for free through the Guild Ambassador. Their ordinary shipment and movement allowances are unchanged.`, { faction: p.faction, name: 'Guild Ambassador shipment' }, ); continueGuildAmbassadorShipment(g, entry.event); } function continueAmbassadorArrival(g: Game, event: string) { if (g.pendingAmbassador?.effect === 'guild') continueGuildAmbassadorShipment(g, event); else continueAmbassadorRelocation(g, event); } function continueGuildAmbassadorShipment(g: Game, event: string) { const entry = g.pendingAmbassador; requireRule( entry?.event === event && entry.stage === 'arrival' && entry.effect === 'guild' && entry.shipmentReceipt && entry.turn === g.turn && entry.phase === g.phase, 'This Guild Ambassador arrival is no longer current.', ); const receipt = entry.shipmentReceipt; const { order } = guildAmbassadorArrival(g, event, receipt.next); const p = getPlayer(g, order.player); if (receipt.next === 'intrusion') { receipt.next = 'terror'; if (intrusion(g, p, order.territory, { ambassadorEvent: event })) return; } if (receipt.next === 'terror') { receipt.next = 'accompany'; if ( openTerrorEntry( g, p, order.territory, order.sector, order.amount, order.elite, 'ambassador', 'ambassador', event, ) ) return; } if (receipt.next === 'accompany') { receipt.next = 'advisor'; const bg = byFaction(g, 'beneGesserit'); if (p.faction !== 'fremen' && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0) { g.decision = { kind: 'advisor', player: bg.id, shipment: p.id, destination: location(order.territory, order.sector), ambassadorEvent: event, }; return; } } finishAmbassador(g); } function guildAdvisorAmbassador( g: Game, quote: ReturnType, ) { const owner = byFaction(g, 'ecaz'); const token = g.ecazAmbassadors?.tokens.find( (token) => token.zone === 'placed' && token.location === quote.territory, ); const bg = getPlayer(g, quote.player); return ( !!owner && !!token && canTriggerAmbassador({ owner: owner.id, ally: owner.ally, entrant: bg.id, entrantFaction: bg.faction, advisors: quote.advisors, effect: token.effect, }) ); } function validateGuildAdvisorEntry( g: Game, quote: ReturnType, ) { if (!guildAdvisorAmbassador(g, quote)) return; const moritani = byFaction(g, 'moritani'), bg = getPlayer(g, quote.player); const terror = moritani && moritani.id !== bg.id && moritani.ally !== bg.id && !homeworldTerrorEntryBlock(g, quote.amount) && g.moritaniTerror?.tokens.some( (t) => t.status === 'placed' && t.location === quote.territory, ); requireRule( !terror, 'Ambassadors combined with Terror on the same arrival are still being implemented. Choose another accompaniment destination or decline.', ); } function guildAdvisorChoices(g: Game, player: string) { const decision = g.decision; if ( decision?.kind !== 'advisor' || decision.player !== player || !decision.ambassadorEvent ) return null; const { order } = guildAmbassadorArrival( g, decision.ambassadorEvent, 'advisor', ); const candidates = [ ...(g.advanced ? territory(order.territory).sectors.map((sector) => ({ accompany: true, territory: order.territory, sector, amount: 1, })) : []), ...Array.from({length: Math.max(1, spiritualAdvisorMaximum(g, player))}, (_, index) => ({accompany: false, territory: 'polar_sink', sector: 0, amount: index + 1})), ]; return { choices: candidates.map((choice) => { try { const quoted = guildAmbassadorRule(() => quoteGuildAmbassadorAdvisor(g, player, order, { type: 'decision', ...choice, }), ); validateGuildAdvisorEntry(g, quoted); return { ...choice, blocked: null as string | null }; } catch (error) { if (!(error instanceof RuleError)) throw error; return { ...choice, blocked: error.message }; } }), }; } function finishGuildAmbassadorAdvisor( g: Game, response: ResponseWindow, canceled: boolean, ) { const event = response.advisorAmbassadorEvent; requireRule( typeof event === 'string', 'The accompanying advisor has lost its shipment event.', ); const { order } = guildAmbassadorArrival(g, event, 'advisor'); // Cancellation checks the committed source, not resources or destinations that // can change during a reaction to the declared faction power. try { validateTerminalCancellation(g, response); } catch (error) { if (error instanceof TerminalCancellationError) throw new RuleError(error.message); throw error; } if (canceled) { log( g, 'Karama prevented Bene Gesserit accompaniment to the Guild Ambassador shipment. The original shipment remains committed.', ); finishAmbassador(g); return; } const at = splitLocation(response.location!); const quote = guildAmbassadorRule(() => quoteGuildAmbassadorAdvisor(g, response.owner, order, { type: 'decision', accompany: at.territory !== 'polar_sink', sector: at.sector, territory: at.territory, amount: response.amount ?? 1, }), ); validateGuildAdvisorEntry(g, quote); const bg = getPlayer(g, quote.player); withdrawNativeReserves(g, bg, quote.amount, 0); place(bg, quote.territory, quote.sector, quote.amount); techIncome(g, 'heighliners', bg); if (quote.advisors) (bg.advisors ??= {})[quote.territory] = { lockedTurn: g.turn }; const receipt = g.pendingAmbassador!.shipmentReceipt!; receipt.next = 'finish'; receipt.advisorArrival = { player: bg.id, territory: quote.territory, sector: quote.sector, amount: quote.amount, elite: 0, }; log( g, `${bg.name} sent ${quote.amount} free reserve ${quote.amount === 1 ? 'force' : 'forces'} to ${territory(quote.territory).name}, sector ${quote.sector}, after the Guild Ambassador shipment${quote.amount === 2 ? ' using high-population Wallach IX' : ''}.`, { faction: bg.faction, name: 'Spiritual Advisors' }, ); if (guildAdvisorAmbassador(g, quote)) { const parent = g.pendingAmbassador!; const arrivalTag = parent.revivalEvent !== undefined ? 'revivalEvent' : parent.victoryEvent !== undefined ? 'victoryEvent' : undefined; const arrival = arrivalTag === 'revivalEvent' ? g.homeworldRevivalReturn : arrivalTag === 'victoryEvent' ? g.homeworldVictoryReinforcement : undefined; if (arrivalTag !== undefined) requireRule(arrival && ambassadorPhaseAllowed(g), 'This Ambassador accompaniment has lost its original Homeworld arrival.'); const resume = parent.resume; const wormRider = parent.wormRider ?? (resume === 'wormRide' ? parent.entrant : undefined); // Accompaniment is the final suffix: transfer its outer continuation rather // than replaying or retaining the completed Guild shipment. finishAmbassador(g, true); requireRule( openTerritoryEntry( g, bg, quote.territory, quote.sector, quote.amount, 0, 'advisor', resume, ), 'The accompanying fighter has lost its Ambassador opportunity.', ); g.pendingAmbassador!.wormRider = wormRider; g.pendingAmbassador!.guildAdvisorOrigin = { event, player: order.player, territory: order.territory, sector: order.sector, }; if (arrival && arrivalTag) { requireRule(validAmbassadorResume(g, g.pendingAmbassador!), 'The accompanying Ambassador has lost its completed Guild shipment.'); g.pendingAmbassador![arrivalTag] = arrival.event; appendHomeworldArrivalAmbassador(arrival, g.pendingAmbassador!, parent.event); } } else if ( !openTerrorEntry( g, bg, quote.territory, quote.sector, quote.amount, 0, 'ambassador', 'ambassador', event, ) ) finishAmbassador(g); } function quoteAmbassadorRelocation(g: Game, action: Action) { const { beneficiary } = currentFremenAmbassador(g, stringField(action.event)); try { return quoteFremenAmbassadorMove(g, beneficiary.id, action); } catch (error) { if (error instanceof FremenAmbassadorMoveError) throw new RuleError(error.message); throw error; } } function offerAmbassadorRelocation(g: Game) { const entry = g.pendingAmbassador!; entry.stage = 'move'; const { beneficiary } = currentFremenAmbassador(g); if (fremenAmbassadorMovement(g, beneficiary.id).sources.length) { g.decision = { kind: 'ecazAmbassador', player: beneficiary.id }; } else { log( g, `${beneficiary.name} has no legal Fremen Ambassador relocation. No forces or ordinary movement allowances changed; the trigger remains committed.`, ); finishAmbassador(g); } } function ambassadorRelocationArrivalBlock( g: Game, player: string, origin: string, to: string, entering: number, ): string | null { const mover = getPlayer(g, player); if (origin === to) return null; const bg = byFaction(g, 'beneGesserit'); const moritani = byFaction(g, 'moritani'); const intrudes = g.advanced && bg && bg.id !== mover.id && fighterCount(bg, to) > 0; const terror = moritani && mover.id !== moritani.id && mover.id !== moritani.ally && !homeworldTerrorEntryBlock(g, entering) && g.moritaniTerror?.tokens.some( (token) => token.status === 'placed' && token.location === to, ); return intrudes && terror ? 'The order of simultaneous Intrusion and Terror is awaiting a table interpretation. This relocation has not been committed.' : null; } function validateAmbassadorRelocationArrival( g: Game, move: FremenAmbassadorMove, ) { const blocked = ambassadorRelocationArrivalBlock( g, move.player, move.origin, move.to, move.total, ); requireRule(!blocked, blocked ?? 'This arrival is unavailable.'); } function ambassadorArrivalChoices(g: Game, player: string, origin: string, to: string) { const blocked = ambassadorRelocationArrivalBlock(g, player, origin, to, 1); const largerBlocked = ambassadorRelocationArrivalBlock(g, player, origin, to, 3); return { blocked, maximum: !blocked && largerBlocked ? 2 : null }; } function ambassadorRelocationMovement(g: Game, player: string) { return { sources: fremenAmbassadorMovement(g, player).sources.map((source) => ({ ...source, destinations: source.destinations.map((destination) => ({ ...destination, ...ambassadorArrivalChoices( g, player, source.territory, destination.territory, ), })), })), }; } function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) { const { entry, beneficiary: p } = currentFremenAmbassador(g); validateAmbassadorRelocationArrival(g, move); if (move.noField) { requireRule( p.noField && p.noFieldEvent === move.noField.event, 'The concealed marker selection is no longer current.', ); p.noField = noFieldRule(() => moveRicheseNoField(p.noField!, move.noField!.tokenId, { territory: move.to, sector: move.sector, }), ); p.noFieldEvent = crypto.randomUUID(); } removeGroup(p, move.group, move.eliteGroup); const physical = move.total - (move.noField ? 1 : 0); if (physical) place(p, move.to, move.sector, physical, move.elite); if (move.advisors) { const lockedTurn = Math.max(move.lockedTurn ?? 0, p.advisors?.[move.to]?.lockedTurn ?? 0); (p.advisors ??= {})[move.to] = lockedTurn ? { lockedTurn } : {}; } else if (p.advisors) delete p.advisors[move.to]; entry.stage = 'arrival'; entry.relocation = { order: move, next: 'flip' }; log( g, `${p.name} relocated ${physical} physical forces${move.noField ? ' and a concealed No-Field' : ''} from ${territory(move.origin).name} to ${territory(move.to).name}, sector ${move.sector}, through the Fremen Ambassador. Their ordinary shipment and movement allowances are unchanged.`, { faction: p.faction, name: 'Fremen Ambassador relocation' }, ); continueAmbassadorRelocation(g, entry.event); } function continueAmbassadorRelocation(g: Game, event: string) { const { entry, beneficiary: p } = currentFremenAmbassador(g, event, true); const receipt = entry.relocation!, move = receipt.order; if (receipt.next !== 'flip') { try { validateAmbassadorRelocationContext(g, event, receipt.next); } catch (error) { if (error instanceof MovementCancellationError) throw new RuleError(error.message); throw error; } } if (receipt.next === 'flip') { receipt.next = 'intrusion'; if (move.wantsFighters) { g.response = { kind: 'advisorFlip', owner: p.id, location: move.to, advisors: false, passed: [], advisorResume: 'ambassador', advisorAmbassadorEvent: event, }; return; } } if (receipt.next === 'intrusion') { receipt.next = 'terror'; if ( move.origin !== move.to && intrusion(g, p, move.to, { ambassadorEvent: event }) ) return; } if (receipt.next === 'terror') { receipt.next = 'finish'; if ( move.origin !== move.to && openTerrorEntry( g, p, move.to, move.sector, move.total, move.elite, 'ambassador', 'ambassador', event, ) ) return; } finishAmbassador(g); } /** These are consequences of a completed purchase, never an auction lot. */ function validateAmbassadorPurchaseResponse(g: Game, response: ResponseWindow) { const entry = g.pendingAmbassador; const receipt = entry?.purchaseReceipt; const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry?.token); requireRule( response.source === 'ambassador' && (response.kind === 'emperorIncome' || response.kind === 'harkonnenBonus') && g.status === 'playing' && entry && receipt && entry.turn === g.turn && entry.phase === g.phase && ambassadorPhaseAllowed(g) && response.intent === entry.event && entry.effect === 'richese' && entry.beneficiary === receipt.buyer && receipt.amount === 3 && typeof receipt.card === 'string' && !!receipt.card && entry.stage === receipt.stage && byFaction(g, 'ecaz')?.id === entry.owner && g.players.some((p) => p.id === entry.entrant) && token && ((token.effect === 'richese' && token.zone === 'used') || (token.effect === 'beneGesserit' && token.zone === 'removed' && entry.copyChoices.includes('richese'))), 'This Ambassador purchase continuation is no longer current.', ); const buyer = getPlayer(g, receipt.buyer); const emperor = byFaction(g, 'emperor'); requireRule( receipt.emperor === (emperor && emperor.id !== buyer.id ? emperor.id : null), 'This Ambassador purchase has an invalid income recipient.', ); requireRule( response.kind === 'emperorIncome' ? entry.stage === 'income' && response.owner === receipt.emperor && response.amount === 3 : entry.stage === 'bonus' && response.owner === buyer.id && buyer.faction === 'harkonnen', 'This faction benefit does not belong to the current Ambassador purchase.', ); return { entry, receipt, buyer }; } function ambassadorPurchaseBonus(g: Game) { const entry = g.pendingAmbassador!; const receipt = entry.purchaseReceipt!; const buyer = getPlayer(g, receipt.buyer); if (buyer.faction === 'harkonnen' && buyer.hand.length < handLimit(buyer)) { entry.stage = receipt.stage = 'bonus'; g.response = { kind: 'harkonnenBonus', source: 'ambassador', intent: entry.event, owner: buyer.id, passed: [], }; } else finishAmbassador(g); } /** The payer has already paid. Only credit the eligible faction share once. */ function creditFactionPayment(g: Game, ownerId: string, kind: 'shipment' | 'treachery', gross: number) { const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, ownerId, kind, gross)); const owner = getPlayer(g, ownerId); requireRule(Number.isSafeInteger(owner.spice + payment.income), 'The faction payment would overflow its spice balance.'); owner.spice += payment.income; return payment; } function currentFactionPayment(g: Game) { const response = g.response; if (!g.homeworlds?.custody || !response) return null; const kind = response.kind === 'guildIncome' ? 'shipment' : ['emperorIncome', 'richesePurchaseIncome'].includes(response.kind) ? 'treachery' : null; if (!kind) return null; if (response.kind === 'emperorIncome' && response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, response); if (response.kind === 'richesePurchaseIncome') { const pending = g.pendingRichesePurchaseIncome; requireRule(pending && pending.owner === response.owner && pending.turn === g.turn && pending.phase === g.phase && response.amount === 3, 'This Richese purchase income is no longer current.'); } const gross = response.kind === 'emperorIncome' ? response.source === 'ambassador' ? response.amount! : g.currentAuctionSale?.amount ?? g.auction?.bid : response.amount; requireRule(gross !== undefined, 'The faction payment is missing its original amount.'); if (kind === 'shipment' && (Object.hasOwn(response, 'guildContributions') || Object.hasOwn(response, 'guildPaymentProof'))) { requireRule(Array.isArray(response.guildContributions) && response.guildPaymentProof === guildPaymentSignature(g, response.owner, gross, response.guildContributions), 'The saved Guild payment no longer matches its original contributor receipt.'); validateGuildPaymentRounding(g, response.owner, gross, response.guildContributions); } return { owner: response.owner, kind, ...homeworldRule(() => quoteHomeworldPaymentIncome(g, response.owner, kind, gross)) }; } function validateGuildPaymentRounding(g: Game, owner: string, gross: number, contributions: number[]) { if (!g.homeworlds?.custody) return; const rounding = homeworldRule(() => quoteGuildPaymentRounding(gross, contributions)); const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, owner, 'shipment', gross)); requireRule(!payment.low || rounding.unambiguous, 'Low Junction rounding for two odd allied contributions awaits a ruling. Choose a different payment split.'); } function shipmentIncomeContributions(g: Game, p: Player, cost: number, allyPayment: number) { const guild = byFaction(g, 'guild'); if (!guild || g.karamaShipping?.player === p.id) return []; return [p.id === guild.id ? 0 : cost - allyPayment, p.ally === guild.id ? 0 : allyPayment].filter(amount => amount > 0); } function checkShipmentIncomeRounding(g: Game, p: Player, cost: number, allyPayment: number) { const guild = byFaction(g, 'guild'); if (!guild) return; const amounts = shipmentIncomeContributions(g, p, cost, allyPayment); checkBureaucratContributors(g,g.karamaShipping?.player === p.id ? undefined : guild.id,[{payer:p.id,amount:p.id === guild.id ? 0 : cost-allyPayment}, ...(allyPayment ? [{payer:p.ally!,amount:p.ally === guild.id ? 0 : allyPayment}] : [])],allyPayment > 0); validateGuildPaymentRounding(g, guild.id, amounts.reduce((sum, amount) => sum + amount, 0), amounts); } function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:string): ResponseWindow { const contributions = amounts.filter(amount => amount > 0); const amount = contributions.reduce((sum, value) => sum + value, 0); validateGuildPaymentRounding(g, owner, amount, contributions); return { kind: 'guildIncome', owner, amount, passed: [], ...(contributions.length === 1 && payer ? stampBureaucratPayment(g,'shipment',payer,owner,amount) : {}), ...(g.homeworlds?.custody ? { guildContributions: contributions, guildPaymentProof: guildPaymentSignature(g, owner, amount, contributions) } : {}) }; } function guildPaymentSignature(g: Game, owner: string, amount: number, contributions: number[]) { return JSON.stringify({ turn: g.turn, phase: g.phase, owner, amount, contributions }); } function finishAmbassadorPurchaseResponse( g: Game, response: ResponseWindow, canceled: boolean, ) { const { receipt, buyer } = validateAmbassadorPurchaseResponse(g, response); if (response.kind === 'emperorIncome') { const payment = !canceled ? creditFactionPayment(g, response.owner, 'treachery', receipt.amount) : null; log( g, canceled ? 'Karama prevented Emperor income from the Richese Ambassador purchase. The three-spice payment stays in the bank; the buyer keeps the card.' : `${getPlayer(g, response.owner).name} collected ${payment!.income} spice from the three-spice Richese Ambassador purchase payment.${payment!.bank ? ` Low-population Kaitain leaves ${payment!.bank} spice in the bank.` : ''}`, { faction: 'emperor', name: canceled ? 'Purchase income prevented' : 'Card purchase income', }, ); ambassadorPurchaseBonus(g); } else { const bonus = !canceled && buyer.hand.length < handLimit(buyer) ? draw(g) : undefined; if (bonus) buyer.hand.push(bonus); log( g, canceled ? 'Karama prevented the Harkonnen bonus card from the Richese Ambassador purchase. The paid card remains with its buyer.' : bonus ? `${buyer.name} drew a private Harkonnen bonus Treachery Card after the Richese Ambassador purchase.` : `${buyer.name} received no Harkonnen bonus card because their hand has no room or the deck has no available card.`, { faction: 'harkonnen', name: canceled ? 'Bonus card prevented' : 'Bonus treachery card', }, ); finishAmbassador(g); } } function resolveAmbassadorEffect(g: Game) { const entry = g.pendingAmbassador!; const p = getPlayer(g, entry.beneficiary!); const entrant = getPlayer(g, entry.entrant); const effect = entry.effect!; requireRule( effect !== 'ecaz', 'Choose the Ecaz Ambassador’s direct Duke acquisition from its current offer.', ); const blocked = ambassadorEffectBlock(g, effect, p, entrant); requireRule(!blocked, blocked ?? 'This Ambassador effect is unavailable.'); if (effect === 'beneGesserit') { entry.stage = 'copy'; g.decision = { kind: 'ecazAmbassador', player: p.id }; return; } if (effect === 'guild') { offerAmbassadorShipment(g); return; } if (effect === 'fremen') { offerAmbassadorRelocation(g); return; } if (effect === 'richese') { // Resolve after commitment so Ecaz cannot probe its ally's private funds. if ( p.spice < 3 || p.hand.length >= handLimit(p) || (!g.deck.length && !g.discard.length) ) { log( g, `${p.name} received no card through the Richese Ambassador. No spice was charged; the trigger remains committed.`, ); finishAmbassador(g); return; } const card = draw(g); requireRule( card, 'No Treachery Card is available for the Ambassador purchase.', ); p.spice -= 3; p.hand.push(card); const emperor = byFaction(g, 'emperor'); entry.purchaseReceipt = { buyer: p.id, card: card.id, amount: 3, emperor: emperor && emperor.id !== p.id ? emperor.id : null, stage: 'income', }; log( g, `${p.name} paid 3 spice and drew the top Treachery Card through the Richese Ambassador. Its identity remains private.`, { faction: p.faction, name: 'Richese Ambassador purchase' }, ); if (entry.purchaseReceipt.emperor) { entry.stage = 'income'; g.response = { kind: 'emperorIncome', source: 'ambassador', intent: entry.event, owner: entry.purchaseReceipt.emperor, amount: 3, passed: [], }; } else ambassadorPurchaseBonus(g); return; } if ( effect === 'ixians' || (effect === 'choam' && p.hand.some((c) => !ambassadorDiscardBlock(g, p, c, effect))) ) { entry.stage = 'cards'; g.decision = { kind: 'ecazAmbassador', player: p.id }; return; } if (effect === 'emperor') { p.spice += 5; log( g, `${p.name} gained 5 spice from the Spice Bank through the Emperor Ambassador.`, { faction: p.faction, name: 'Emperor Ambassador' }, ); } else if (effect === 'atreides' || effect === 'harkonnen') { const held = entrant.faction === 'tleilaxu' ? (entrant.faceDancers ?? []).map((c) => c.leader) : entrant.traitors; (g.ambassadorInsights ??= []).push({ event: entry.event, viewer: p.id, target: entrant.id, turn: g.turn, effect, cards: effect === 'atreides' ? structuredClone(entrant.hand) : [], traitor: effect === 'harkonnen' ? held[Math.floor(random() * held.length)] : null, }); log( g, `${p.name} privately inspected ${effect === 'atreides' ? `${entrant.name}’s current Treachery hand` : `one random Traitor Card held by ${entrant.name}`} through the ${faction(effect).name} Ambassador. This records only what was held at this moment.`, { faction: p.faction, name: `${faction(effect).name} Ambassador` }, ); } else if (effect === 'choam') { log( g, `${p.name} had no available Treachery cards to discard through the CHOAM Ambassador and gained no spice.`, ); } finishAmbassador(g); } function ecazAllianceQuote(g: Game, owner: string, entrant: string) { const blocked = homeworldAllianceReason(g, owner, entrant); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); try { return quoteEcazAlliance(g, owner, entrant); } catch (error) { if (error instanceof EcazAllianceError) throw new RuleError(error.message); throw error; } } function ecazAllianceIntegrity(g: Game) { const entry = g.pendingAmbassador; const continuation = g.pendingTreacheryDiscard?.continuation; const controls = [ g, g.pendingExchange, g.pendingRicheseGift?.resume, g.pendingNullentropy?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null, ]; if (!entry) { requireRule( !controls.some((control) => control?.decision?.kind === 'ecazAmbassador'), 'The Ambassador decision has lost its entry event.', ); return; } if (entry.stage !== 'allianceReply') return; const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry.token); requireRule( g.status === 'playing' && entry.turn === g.turn && entry.phase === g.phase && ambassadorPhaseAllowed(g) && typeof entry.event === 'string' && entry.event.length > 0 && entry.effect === 'ecaz' && entry.beneficiary === entry.entrant && token?.effect === 'ecaz' && token.zone === 'supply' && token.location === null && controls.some( (control) => control?.decision?.kind === 'ecazAmbassador' && control.decision.player === entry.entrant, ), 'The Ecaz alliance reply has lost its original Ambassador or decision owner.', ); ecazAllianceQuote(g, entry.owner, entry.entrant); } function decideAmbassador(g: Game, p: Player, action: Action) { const entry = g.pendingAmbassador; requireRule( entry && entry.turn === g.turn && entry.phase === g.phase && action.event === entry.event, 'This Ambassador opportunity is no longer current.', ); const owner = getPlayer(g, entry.owner), entrant = getPlayer(g, entry.entrant); requireRule( p.id === (entry.stage === 'offer' ? owner.id : entry.beneficiary), 'The Ambassador choice belongs to another player.', ); if (entry.stage === 'allianceReply') { requireRule( typeof action.accept === 'boolean', 'Accept or refuse the Ecaz alliance offer.', ); const quote = ecazAllianceQuote(g, owner.id, entrant.id); if (action.accept) { for (const member of quote.players) { if (member.id !== owner.id && member.id !== entrant.id) continue; const target = getPlayer(g, member.id); target.ally = member.ally; target.allySinceTurn = member.allySinceTurn; } g.allianceOffers = quote.allianceOffers; discardAllianceNexusCards(g, owner, entrant); g.ready = []; log( g, `${owner.name} and ${entrant.name} formed an alliance through the Ecaz Ambassador. Both were unallied and ${entrant.name} accepted. Their alliance abilities apply immediately; the entrant’s remaining actions resume.`, { faction: 'ecaz', name: 'Ambassador alliance' }, ); } else log( g, `${entrant.name} refused the Ecaz Ambassador alliance. No alliance or Duke transfer occurred; the triggered token remains in Ecaz’s supply and the entrant’s remaining actions resume.`, ); finishAmbassador(g); return; } if (entry.stage === 'offer') { const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry.token); requireRule( token?.zone === 'placed' && token.location === entry.territory, 'The Ambassador is no longer in that stronghold.', ); if (action.decline === true) { log( g, `${owner.name} left the ${faction(token.effect).name} Ambassador in ${territory(entry.territory).name} without triggering it.`, ); finishAmbassador(g); return; } requireRule( action.trigger === true, 'Trigger the Ambassador or leave it in place.', ); if (token.effect === 'ecaz' && action.choice === 'alliance') { requireRule( action.beneficiary === owner.id, 'Ecaz must offer its own Ambassador alliance.', ); ecazAllianceQuote(g, owner.id, entrant.id); requireRule( canTriggerAmbassador({ owner: owner.id, ally: owner.ally, entrant: entrant.id, entrantFaction: entrant.faction, advisors: isAdvisor(entrant, entry.territory), effect: token.effect, }), 'This entrant cannot trigger the Ecaz Ambassador.', ); entry.effect = 'ecaz'; entry.beneficiary = entrant.id; entry.stage = 'allianceReply'; g.ecazAmbassadors = triggerAmbassador(g.ecazAmbassadors!, token.id); g.decision = { kind: 'ecazAmbassador', player: entrant.id }; log( g, `${owner.name} offered an alliance to ${entrant.name} through the Ecaz Ambassador. The reusable token returned to supply; the five random Ambassadors are unchanged. Only the entrant can accept or refuse. Duke Vidal has not changed hands.`, ); return; } const beneficiary = getPlayer(g, stringField(action.beneficiary)); requireRule( beneficiary.id === owner.id || (beneficiary.id === owner.ally && beneficiary.ally === owner.id), 'Choose Ecaz or its current ally as beneficiary.', ); const blocked = ambassadorEffectBlock( g, token.effect, beneficiary, entrant, ); requireRule(!blocked, blocked ?? 'This effect is unavailable.'); if (token.effect === 'beneGesserit') requireRule( entry.copyChoices.some( (effect) => !ambassadorEffectBlock(g, effect, beneficiary, entrant), ), 'No copied effect is currently available.', ); let acquiredDuke: DukeState | null = null; if (token.effect === 'ecaz') { requireRule( action.choice === 'duke' && beneficiary.id === owner.id, 'Choose the Ecaz Ambassador’s direct acquisition of Duke Vidal for Ecaz.', ); requireRule( canTriggerAmbassador({ owner: owner.id, ally: owner.ally, entrant: entrant.id, entrantFaction: entrant.faction, advisors: isAdvisor(entrant, entry.territory), effect: token.effect, }), 'This entrant cannot trigger the Ecaz Ambassador.', ); try { acquiredDuke = quoteEcazDukeAcquisition(g, owner.id); } catch (error) { if (error instanceof EcazDukeAcquisitionError) throw new RuleError(error.message); throw error; } } entry.beneficiary = beneficiary.id; entry.effect = token.effect; g.ecazAmbassadors = triggerAmbassador(g.ecazAmbassadors!, token.id); log( g, `${owner.name} triggered the ${faction(token.effect).name} Ambassador for ${beneficiary.name}. ${token.effect === 'ecaz' ? 'The reusable Ecaz token returned to supply; the five random Ambassadors are unchanged.' : token.effect === 'beneGesserit' ? 'The Bene Gesserit token was permanently removed.' : 'The token was set aside until the five random Ambassadors have all been triggered.'}`, ); if (acquiredDuke) { const previous = g.dukeVidal!.controller; g.dukeVidal = acquiredDuke; log( g, `${owner.name} acquired Duke Prad Vidal${previous ? ` from ${getPlayer(g, previous).name}` : ''} through the Ecaz Ambassador. Ecaz keeps the shared Duke until battle use or Moritani acquisition; the entrant’s remaining actions resume.`, { faction: owner.faction, name: 'Ecaz Ambassador — Duke Vidal' }, ); finishAmbassador(g); } else resolveAmbassadorEffect(g); } else if (entry.stage === 'copy') { const effect = stringField(action.effect) as AmbassadorEffect; requireRule( entry.copyChoices.includes(effect), 'Copy an Ambassador outside the original supply for this cycle.', ); entry.effect = effect; resolveAmbassadorEffect(g); } else if (entry.stage === 'ship') { currentGuildAmbassador(g, stringField(action.event)); if (action.amount === 0) { requireRule( action.noField === undefined && action.alliedNoField === undefined, 'Choose physical reserve forces for this shipment.', ); log( g, `${p.name} chose to send no reserve forces through the Guild Ambassador. The trigger remains committed and their ordinary shipment is unchanged.`, ); finishAmbassador(g); } else { const order = quoteAmbassadorShipment(g, action); validateAmbassadorShipmentArrival(g, order); offerShipment(g, { ...order, turn: g.turn, source: 'ambassador', ambassadorEvent: entry.event, }); } } else if (entry.stage === 'move') { const move = quoteAmbassadorRelocation(g, action); validateAmbassadorRelocationArrival(g, move); offerChoamMovement(g, { ...move, source: 'ambassador', ambassadorEvent: entry.event, }); } else { requireRule( entry.effect === 'choam' || entry.effect === 'ixians', 'This Ambassador has no card selection.', ); requireRule( Array.isArray(action.cards) && action.cards.every((id) => typeof id === 'string') && new Set(action.cards).size === action.cards.length, 'Choose distinct owned Treachery cards.', ); const ids = action.cards as string[]; requireRule( entry.effect !== 'ixians' || ids.length === 1, 'Discard exactly one card for the Ixian Ambassador.', ); for (const id of ids) { const card = p.hand.find((c) => c.id === id); requireRule(card, 'Choose a card in your own hand.'); const blocked = ambassadorDiscardBlock(g, p, card, entry.effect); requireRule(!blocked, blocked ?? 'This card cannot be discarded.'); } const entries = ids.map((id) => ({ card: discard(g, p, id), discardedBy: p.id, publicFace: false, })); if (entry.effect === 'choam') { p.spice += ids.length * 3; log( g, `${p.name} discarded ${ids.length} Treachery cards and gained ${ids.length * 3} bank spice through the CHOAM Ambassador.`, ids.length ? { faction: p.faction, name: 'CHOAM Ambassador' } : undefined, ); } if (!entries.length) finishAmbassador(g); else { g.pendingAmbassador = null; stageTreacheryDiscard(g, `ambassador:${entry.effect}`, entries, { kind: 'ambassador', entry, }); } } } /** Card interruptions keep the live entry; fresh discard owns its one suspended copy. */ function terrorEntryIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const entries = [g.pendingTerrorEntry, (continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics') ? continuation.entry : null]; for (const entry of entries) { if (!entry) continue; homeworldRule(() => validateTerrorEntrySignature(entry)); if (entry.entrySignature === undefined) continue; requireRule(entry.turn === g.turn && entry.phase === g.phase && g.players.some((p) => p.id === entry.entrant && p.faction !== 'moritani') && g.players.filter((p) => p.faction === 'moritani').length === 1, 'The saved Terror entry has lost its original turn or seated participants.'); if (entry.candidates) { const committedDiscard = (continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics') && entry === continuation.entry; const reserved = entry.candidates.filter((id) => id !== entry.token || (!committedDiscard && ['select', 'offer', 'allianceResponse', 'allianceReply'].includes(entry.stage))); requireRule(reserved.every((id) => g.moritaniTerror?.tokens.filter((token) => token.id === id && token.status === 'placed' && token.location === entry.territory).length === 1), 'The stacked Terror choice has lost an original reserved token.'); if (committedDiscard) requireRule(g.moritaniTerror?.tokens.some((token) => token.id === entry.token && token.status === 'removed'), 'The stacked Terror discard has lost its consumed token.'); } } } /** Public original batch size; concealed No-Field markers count as one. */ function homeworldTerrorEntryBlock(g: Game, entering: number): string | null { const owner = byFaction(g, 'moritani'); return owner ? homeworldRule(() => lowGrummanRevealBlock(g, owner.id, entering)) : null; } /** Entry is already paid and committed; a reaction must never replay its original action. */ function openTerrorEntry( g: Game, entrant: Player, to: string, sector: number, amount: number, elite: number, cause: NonNullable['cause'], resume: 'none' | 'wormRide' | 'ambassador' = 'none', ambassadorEvent?: string, ) { const moritani = byFaction(g, 'moritani'); const overlap = g.pendingArrivalOverlap; const originallyTriggered = !!overlap && overlap.stage === 'second' && overlap.first === 'ambassador' && overlap.entrant === entrant.id && overlap.territory === to && overlap.turn === g.turn && overlap.phase === g.phase && overlap.cause === cause; const tokens = g.moritaniTerror?.tokens.filter(t => t.status === 'placed' && t.location === to && (!originallyTriggered || overlap!.terrorTokens.includes(t.id))) ?? []; const token = tokens[0]; if (!moritani || !token || entrant.id === moritani.id || (entrant.id === moritani.ally && !originallyTriggered) || homeworldTerrorEntryBlock(g, amount)) return false; requireRule(!g.pendingTerrorEntry, 'Resolve the pending Terror entry first.'); requireRule( !g.response && !g.decision, 'Terror combined with another arrival reaction is still being implemented. This entry has not been committed.', ); g.pendingTerrorEntry = { token: token.id, ...(tokens.length > 1 ? { candidates: tokens.map((t) => t.id) } : {}), entrant: entrant.id, territory: to, sector, amount, elite, cause, turn: g.turn, phase: g.phase, stage: tokens.length > 1 ? 'select' : 'offer', resume, ...(ambassadorEvent ? { ambassadorEvent } : {}), }; if (!TERROR_STRONGHOLDS.includes(to)) { const proofs = tokens.map(token => { const record = g.nexusMoritaniHistory?.find(r => r.stage === 'complete' && r.receipt.token === token.id && r.receipt.territory === to); requireRule(record && terrorLocationAllowed(g,token.id,to,record.receipt.event), 'The Terror token has lost its original Cunning placement.'); return [token.id,record.receipt.event]; }); g.pendingTerrorEntry.nexusPlacements = Object.fromEntries(proofs); } g.pendingTerrorEntry.entrySignature = terrorEntrySignature(g.pendingTerrorEntry); g.decision = { kind: 'moritaniTerror', player: moritani.id, entrant: entrant.id, territory: to, }; log( g, `${moritani.name} may reveal a Terror token after ${entrant.name} entered ${territory(to).name}.`, ); return true; } function finishTerrorEntry(g: Game) { const pending = g.pendingTerrorEntry!; g.pendingTerrorEntry = null; const overlap = g.pendingArrivalOverlap; if (overlap && pending.entrant === overlap.entrant && pending.territory === overlap.territory && pending.turn === overlap.turn && pending.phase === overlap.phase && pending.cause === overlap.cause) { requireRule(pending.resume === 'none', 'The overlapping Terror must not resume its parent twice.'); if (overlap.stage === 'first' && overlap.first === 'terror') { overlap.stage = 'second'; const token = g.ecazAmbassadors?.tokens.find(t => t.id === overlap.ambassadorToken && t.zone === 'placed' && t.location === overlap.territory); requireRule(token, 'The originally triggered Ambassador token is no longer present.'); openAmbassadorEntry(g, getPlayer(g, overlap.ecaz), token, getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, 'none'); overlap.ambassadorEvent = g.pendingAmbassador!.event; return; } requireRule(overlap.stage === 'second' && overlap.first === 'ambassador', 'The second Terror reaction is out of order.'); finishArrivalOverlap(g, overlap); return; } if (pending.resume === 'wormRide') nextWormRide(g); else if (pending.resume === 'ambassador') continueAmbassadorArrival(g, pending.ambassadorEvent!); } /** The token, random victim/draw, discard and arrival have already committed. */ function continueTerrorDiscard( g: Game, source: 'sabotage' | 'robberyOverflow', ) { const entry = g.pendingTerrorEntry!; const owner = byFaction(g, 'moritani')!; const needsChoice = source === 'sabotage' ? owner.hand.length > 0 : owner.hand.length > handLimit(owner); if (needsChoice) { entry.stage = source === 'sabotage' ? 'gift' : 'discard'; g.decision = { kind: 'moritaniTerror', player: owner.id, entrant: entry.entrant, territory: entry.territory, }; } else finishTerrorEntry(g); } /** Keep unsupported custody out of the random draw, without leaking concealed captures. */ function terrorRevealBlocked( g: Game, entry: NonNullable, kind: TerrorKind, ): string | null { const homeworldBlock = homeworldTerrorEntryBlock(g, entry.amount); if (homeworldBlock) return homeworldBlock; if (kind === 'atomics') { try { currentAtomicsQuote(g, entry); return null; } catch (error) { if (error instanceof RuleError) return error.message; throw error; } } if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack' || kind === 'extortion') return null; if (kind !== 'assassination') return 'This Terror effect is still being implemented.'; if (g.advanced && byFaction(g, 'harkonnen')) return 'Assassination with advanced Harkonnen capture is still being implemented.'; const entrant = getPlayer(g, entry.entrant); if (g.dukeVidal?.controller === entrant.id) return 'Assassination with Duke Vidal in the leader pool is still being implemented.'; const catalog = leaders(entrant.faction); if ( catalog.length !== 5 || entrant.leaders.length !== catalog.length || new Set(entrant.leaders.map((leader) => leader.id)).size !== catalog.length || entrant.leaders.some( (leader) => !catalog.some( (native) => native.id === leader.id && native.faction === leader.faction && native.strength === leader.strength, ), ) ) return 'Assassination requires a verified ordinary leader pool; this pool is still being implemented.'; if ( entrant.leaders.some((leader) => leader.capturedBy || leader.gholaBy) || g.players.some( (other) => other.id !== entrant.id && other.leaders.some((leader) => controlsLeader(entrant, leader)), ) ) return 'Assassination with captured or foreign ghola leaders is still being implemented.'; if (!entrant.leaders.some((leader) => !leader.dead)) return 'Assassination with an empty living leader pool needs a verified ruling.'; return null; } function sneakAttackOptions( g: Game, entry: NonNullable, ) { const owner = byFaction(g, 'moritani')!; const maximum = Math.min(5, owner.reserves); let blocked: string | null = null; try { allowedEntry(g, owner, entry.territory, entry.sector); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; } const bg = byFaction(g, 'beneGesserit'); if ( !blocked && bg && (bg.reserves > 0 || (g.advanced && fighterCount(bg, entry.territory) > 0)) ) blocked = 'Sneak Attack combined with Bene Gesserit arrival reactions is still being implemented.'; if (!blocked && g.phase === 5 && g.techTokens) blocked = 'Sneak Attack combined with Heighliners technology is still being implemented.'; return { maximum, blocked }; } function terrorAllianceBlocked( g: Game, entry: NonNullable, kind: TerrorKind, ): string | null { if (entry.allianceBlocked) return 'Karama prevented this alliance opportunity; you may still reveal the token or leave it hidden.'; if (g.moritaniAtomics) return 'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.'; const entrant = getPlayer(g, entry.entrant); const owner = byFaction(g, 'moritani')!; const homeworldBlock = homeworldAllianceReason(g, owner.id, entrant.id); if (homeworldBlock) return homeworldBlock; if (entrant.faction === 'ecaz') return 'Enemy of My Enemy cannot be offered to Ecaz.'; if (entrant.id === owner.id || entrant.id === owner.ally) return 'Enemy of My Enemy requires an opposing entrant.'; if (terrorRevealBlocked(g, entry, kind)) return 'This offer is not yet available because its mandatory effect on refusal is still being implemented.'; return null; } /** Return only unspent escrow for the pairs this special alliance breaks. */ function formTerrorAlliance(g: Game, owner: Player, entrant: Player) { const blocked = homeworldAllianceReason(g, owner.id, entrant.id); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); requireRule(!g.moritaniAtomics, 'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.'); const changed = new Set([owner.id, entrant.id]); for (const member of [owner, entrant]) { if (member.ally) getPlayer(g, member.ally).ally = null; member.ally = null; } for (const [donor, credit] of Object.entries(g.aid)) { if (changed.has(donor) || changed.has(credit.recipient)) { getPlayer(g, donor).spice += credit.amount; delete g.aid[donor]; } } for (const [from, to] of Object.entries(g.allianceOffers)) if (changed.has(from) || changed.has(to)) delete g.allianceOffers[from]; owner.ally = entrant.id; entrant.ally = owner.id; owner.allySinceTurn = entrant.allySinceTurn = g.turn; discardAllianceNexusCards(g, owner, entrant); g.ready = []; } function decideTerror(g: Game, p: Player, action: Action) { const entry = g.pendingTerrorEntry; requireRule( entry && entry.turn === g.turn && entry.phase === g.phase && (entry.stage === 'allianceReply' ? p.id === entry.entrant : p.faction === 'moritani'), 'This Terror entry opportunity is no longer current.', ); if (entry.stage === 'select') { if (action.decline === true) { requireRule(action.token === undefined, 'Declining leaves every stacked token hidden.'); log(g, `${p.name} left all Terror tokens hidden after this entry.`); finishTerrorEntry(g); return; } requireRule(typeof action.token === 'string' && entry.candidates?.includes(action.token), 'Choose one of the original stacked Terror tokens.'); requireRule(action.reveal === undefined && action.alliance === undefined, 'Choose the token before deciding whether to reveal it or offer an alliance.'); entry.token = action.token; entry.stage = 'offer'; entry.selectionSignature = terrorSelectionSignature(entry); entry.entrySignature = terrorEntrySignature(entry); g.decision = { kind: 'moritaniTerror', player: p.id, entrant: entry.entrant, territory: entry.territory }; return; } const token = g.moritaniTerror?.tokens.find((t) => t.id === entry.token); requireRule(token, 'The entry’s Terror token is unavailable.'); const entrant = getPlayer(g, entry.entrant); const owner = byFaction(g, 'moritani')!; const reopen = () => { g.decision = { kind: 'moritaniTerror', player: owner.id, entrant: entrant.id, territory: entry.territory, }; }; if (entry.stage === 'allianceReply') { requireRule( token.status === 'placed' && token.location === entry.territory, 'The offered alliance no longer has a hidden Terror token.', ); requireRule( typeof action.accept === 'boolean', 'Accept or refuse the alliance offer.', ); if (action.accept) { const blocked = terrorAllianceBlocked(g, entry, token.kind); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); formTerrorAlliance(g, owner, entrant); g.moritaniTerror = returnTerror(g.moritaniTerror!, token.id, random); if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[token.id]; log( g, `${owner.name} and ${entrant.name} formed an alliance through Enemy of My Enemy; their former alliances ended and the Terror token returned hidden to supply.`, ); finishTerrorEntry(g); } else { log( g, `${entrant.name} refused Enemy of My Enemy; the Terror token must be revealed.`, ); entry.stage = 'offer'; decideTerror(g, owner, { type: 'decision', reveal: true }); } return; } if (entry.stage === 'offer') { requireRule( token.status === 'placed' && token.location === entry.territory, 'The hidden Terror token is no longer in that stronghold.', ); if (action.alliance === true) { const blocked = terrorAllianceBlocked(g, entry, token.kind); requireRule(!blocked, blocked ?? 'This alliance offer is unavailable.'); entry.stage = 'allianceResponse'; g.response = { kind: 'moritaniAlliance', owner: owner.id, passed: [] }; log( g, `${owner.name} declared Enemy of My Enemy toward ${entrant.name}.`, ); return; } if (action.decline === true) { log(g, `${p.name} left the Terror token hidden.`); finishTerrorEntry(g); return; } requireRule( action.reveal === true, 'Reveal the Terror token or leave it hidden.', ); const atomics = token.kind === 'atomics' ? currentAtomicsQuote(g, entry) : null; const blocked = atomics ? null : terrorRevealBlocked(g, entry, token.kind); requireRule(!blocked, blocked ?? 'This Terror effect is unavailable.'); g.moritaniTerror = revealTerror(g.moritaniTerror!, token.id); if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[token.id]; log( g, `${p.name} revealed ${TERROR_DEFINITIONS[token.kind].name} in ${territory(entry.territory).name}.`, ); if (token.kind === 'robbery') { entry.stage = 'robbery'; reopen(); } else if (token.kind === 'assassination') { const victim = shuffle( entrant.leaders.filter((leader) => !leader.dead), )[0]; victim.dead = true; victim.deaths++; delete victim.usedAt; p.spice += victim.strength; log( g, `${victim.name} was assassinated; ${p.name} received ${victim.strength} spice.`, ); finishTerrorEntry(g); } else if (token.kind === 'sneakAttack') { entry.stage = 'sneakAttack'; reopen(); } else if (token.kind === 'extortion') { requireRule(!g.moritaniExtortion, 'An Extortion obligation is already pending.'); g.moritaniExtortion = extortionRule(() => reserveExtortion({ owner: owner.id, token: token.id, turn: g.turn, }, extortionContext(g))).state; log(g, 'Five bank spice is reserved for Moritani until Mentat Pause; it is not yet spendable.'); finishTerrorEntry(g); } else if (atomics) { resolveAtomics(g, entry, atomics); } else { const victimCard = shuffle(entrant.hand)[0]; if (victimCard) { const discardedHandSize = entrant.hand.length; const card = discard(g, entrant, victimCard.id); log( g, `${entrant.name} discarded a random Treachery card to Sabotage.`, ); g.pendingTerrorEntry = null; stageTreacheryDiscard( g, 'terror:sabotage', [{ card, discardedBy: entrant.id, publicFace: false }], { kind: 'terrorDiscard', source: 'sabotage', owner: owner.id, entry, discardedHandSize, }, ); } else { continueTerrorDiscard(g, 'sabotage'); } } return; } requireRule( token.status === 'removed', 'Reveal the Terror token before resolving its effect.', ); if (entry.stage === 'sneakAttack') { requireRule( token.kind === 'sneakAttack', 'This is not a Sneak Attack opportunity.', ); const options = sneakAttackOptions(g, entry); const amount = integer( action.amount, 0, options.maximum, 'Sneak Attack forces', ); requireRule( action.sector === undefined || action.sector === entry.sector, 'Sneak Attack must enter the recorded sector.', ); if (amount > 0) { requireRule( !options.blocked, options.blocked ?? 'This arrival is unavailable.', ); p.reserves -= amount; place(p, entry.territory, entry.sector, amount); observeOccupation(g); log( g, `${p.name} sent ${amount} reserves into ${territory(entry.territory).name} through Sneak Attack.`, ); } else log(g, `${p.name} sent no forces through Sneak Attack.`); finishTerrorEntry(g); } else if (entry.stage === 'robbery') { requireRule( token.kind === 'robbery' && (action.choice === 'spice' || action.choice === 'card'), 'Choose stolen spice or the top Treachery card.', ); if (action.choice === 'spice') { const amount = Math.ceil(entrant.spice / 2); entrant.spice -= amount; p.spice += amount; log( g, `${p.name} stole half of ${entrant.name}’s available spice, rounded up.`, ); } else { const card = draw(g); if (card) p.hand.push(card); log( g, card ? `${p.name} drew a Treachery card through Robbery.` : 'No Treachery card remained for Robbery.', ); if (p.hand.length > handLimit(p)) { entry.stage = 'discard'; reopen(); return; } } finishTerrorEntry(g); } else if (entry.stage === 'discard') { requireRule( token.kind === 'robbery' && p.hand.length > handLimit(p), 'There is no excess Robbery card to discard.', ); const discardedHandSize = p.hand.length; const card = discard(g, p, stringField(action.card)); log(g, `${p.name} discarded a card after the Robbery draw.`); g.pendingTerrorEntry = null; stageTreacheryDiscard( g, 'terror:robberyOverflow', [{ card, discardedBy: p.id, publicFace: false }], { kind: 'terrorDiscard', source: 'robberyOverflow', owner: owner.id, entry, discardedHandSize, }, ); } else { requireRule( token.kind === 'sabotage', 'This is not a Sabotage gift opportunity.', ); if (action.decline !== true) { const card = p.hand.find((c) => c.id === action.card); requireRule(card, 'Choose a card from your own hand to give.'); requireRule( entrant.hand.length < handLimit(entrant), 'This recipient’s exceptional hand overflow is still being implemented. You may decline the gift.', ); p.hand = p.hand.filter((c) => c.id !== card.id); entrant.hand.push(card); log(g, `${p.name} gave ${entrant.name} a Treachery card after Sabotage.`); } else log(g, `${p.name} declined the optional Sabotage gift.`); finishTerrorEntry(g); } } function removeGroup( p: Player, group: [string, number][], eliteGroup: Record = {}, ) { for (const [key, n] of group) { if (p.elites) { p.elites.forces[key] = (p.elites.forces[key] ?? 0) - (eliteGroup[key] ?? 0); if (!p.elites.forces[key]) delete p.elites.forces[key]; } p.forces[key] -= n; if (!p.forces[key]) delete p.forces[key]; } } function pathBlocked(g: Game, p: Player, key: string, advisors = false) { const l = splitLocation(key); if (l.sector && l.sector === g.storm) return true; return occupancyRule(() => strongholdPathBlocked(g.players, p.id, l.territory, advisors), ); } function boardResolution(quote: () => T): T { try { return quote(); } catch (error) { if (error instanceof BoardResolutionError) throw new RuleError(error.message); throw error; } } function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) { for (const entry of released) delete getPlayer(g, entry.player).advisors?.[entry.territory]; } export function battles(g: Game) { const quote = boardResolution(() => quoteCombatBoard(g)); applyAdvisorReleases(g, quote.released); return battleOrderRule(() => quoteBattleChoosers(quote.battles, g.order, g.battleOrder?.priority)).choices; } function battleOrderRule(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof BattleChooserOrderError) throw new RuleError(error.message); throw error; } } function battleOrderIntegrity(g: Game) { requireRule(!!g.battleOrder === !!g.battleOrderUseEvents, 'The saved battle chooser order is missing its independent use events.'); if (!g.battleOrder) return; requireRule(g.status === 'playing' && g.phase === 6, 'Juice of Sapho battle scheduling expires when the Battle Phase ends.'); battleOrderRule(() => validateBattleChooserOrder(g.battleOrder!, g.order, g.turn, g.battleOrderUseEvents!)); const cards = physicalTreacheryCards(g).filter(card => card.id === 'richese-juice-of-sapho'); requireRule(cards.length === 1 && !!richeseCardDefinition(cards[0]) && cards[0].effect === 'juiceOfSapho', 'The played battle scheduling card has lost its unique physical custody.'); if (g.battle) requireRule(g.battle.chooser && [g.battle.attacker, g.battle.defender].includes(g.battle.chooser) && g.battle.chooserEvent === battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder.uses.length) && g.battle.chooser === [g.battle.attacker, g.battle.defender].sort((a, b) => g.battleOrder!.priority.indexOf(a) - g.battleOrder!.priority.indexOf(b))[0], 'The selected battle lost its original scheduling owner.'); else if (saphoBattleBoundary(g)) requireRule(g.active === battleOrderQuote(g).current, 'The active battle chooser contradicts the remaining scheduling priority.'); } function battleOrderAfterBattle(g: Game) { return g.lastBattleContext?.turn === g.turn ? g.lastBattleContext.event : null; } function battleOrderQuote(g: Game) { return battleOrderRule(() => quoteBattleChoosers(quoteCombatBoard(g).battles, g.order, g.battleOrder?.priority)); } function saphoBattleBoundary(g: Game) { return g.phase === 6 && saphoCleanWindow(g) && !g.battle && !g.pendingTreacheryDiscard && !g.pendingRichesePurchaseIncome && !g.pendingCapture && !g.pendingAuditor && !g.pendingFaceDance && !g.pendingTech && !g.pendingChoamBattleIncome && !g.moritaniRetention && !g.choamMarket; } function projectedBattleOrder(g: Game) { if (g.status !== 'playing' || g.phase !== 6) return null; const quote = battleOrderQuote(g); return {event: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0), phaseEvent: `battleOrder:${g.turn}`, remaining: quote.remaining, current: saphoBattleBoundary(g) ? quote.current : null, uses: g.battleOrder?.uses.map(({event, player, mode}) => ({event, player, mode})) ?? []}; } /** Evaluate only at the committed end of movement, using the normal battle geometry. */ function offerMoritaniDuke(g: Game) { const owner = byFaction(g, 'moritani'); if (!owner || g.dukeAcquisitionTurn === g.turn) return false; g.dukeAcquisitionTurn = g.turn; g.dukeVidal ??= createDukeVidal(); const duke = g.dukeVidal; if (duke.leader.dead || duke.controller === owner.id) return false; if ( duke.leader.capturedBy || duke.leader.gholaBy || (g.advanced && byFaction(g, 'harkonnen')) ) { log( g, 'Duke Vidal acquisition with exceptional capture or ghola custody is still being implemented.', ); return false; } const strongholds = new Set( battles(g) .filter( (battle) => [battle.attacker, battle.defender].includes(owner.id) && !battle.territory.startsWith('homeworld:') && territory(battle.territory).type === 'stronghold' && getPlayer( g, battle.attacker === owner.id ? battle.defender : battle.attacker, ).faction !== 'ecaz', ) .map((battle) => battle.territory), ); if (strongholds.size < 2) return false; g.response = { kind: 'moritaniDuke', owner: owner.id, passed: [] }; log( g, `${owner.name} qualifies to acquire Duke Vidal through battles in ${strongholds.size} strongholds.`, ); return true; } type SaphoOption = { scope: 'onceAround' | 'movement' | 'battleOrder'; event: string; mode: 'first' | 'last'; } | { scope: 'battleAggressor'; event: string; mode: 'aggressor' }; function saphoMovementIntegrity(g: Game) { const last = g.saphoMovementLast; if (last === undefined || last === null) return; requireRule( typeof last === 'object' && !Array.isArray(last) && g.status === 'playing' && g.phase === 5 && last.turn === g.turn && last.event === `movement:${g.turn}` && typeof last.player === 'string' && g.order.includes(last.player), 'The saved Juice of Sapho movement scope is invalid.', ); const remaining = g.movementRemaining; requireRule(remaining, 'The saved Juice of Sapho movement queue is missing.'); validateOrderedOpportunity({ event: last.event, eligible: g.order, completed: g.order.filter((id) => !remaining.includes(id)), remaining, current: remaining[0] ?? null, currentStarted: false, protectedLast: last.player, }); } function saphoCleanWindow(g: Game) { return ( g.status === 'playing' && !g.truthtrance && !g.phaseOpening && !g.response && !g.decision && !g.pendingNullentropy && !g.pendingRicheseGift && !g.pendingExchange && !g.pendingKarama && !g.pendingShipment && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingFremenMove && !g.pendingIxMove && !g.pendingChoamMove && !g.richeseAllyOffer && !g.currentAuctionSale ); } function saphoMovementStarted(g: Game, p: Player): boolean { // A paid/reserved movement preparation also starts the combined turn. return !!( p.shipped || p.moved > 0 || g.hajr.includes(p.id) || g.karamaShipping || g.ornithopter || (g.choamMovement?.turn === g.turn && p.faction === 'choam') ); } function saphoMovementQueue(g: Game): OrderedOpportunity | null { let remaining = g.movementRemaining; if ( g.phase !== 5 || !remaining?.length || !g.active || !remaining.includes(g.active) ) return null; const active = getPlayer(g, g.active); // The Guild can already have claimed the next entire turn without rewriting // the physical queue. Preserve that granted current turn in this local scope. if (g.active !== remaining[0]) { if (!g.advanced || active.faction !== 'guild' || !g.guildTimingGranted) return null; remaining = [g.active, ...remaining.filter((id) => id !== g.active)]; } const state: OrderedOpportunity = { event: `movement:${g.turn}`, eligible: g.order, completed: g.order.filter((id) => !remaining.includes(id)), remaining, current: g.active, currentStarted: saphoMovementStarted(g, active), protectedLast: g.saphoMovementLast?.player ?? null, }; validateOrderedOpportunity(state); return state; } /** Own custody and public finite scopes only; no opponent hand scan. */ function saphoOptions(g: Game, p: Player): SaphoOption[] { if (!saphoCleanWindow(g)) return []; const card = p.hand.find((c) => c.id === 'richese-juice-of-sapho'); if ( !card || card.effect !== 'juiceOfSapho' || !richeseCardDefinition(card) || p.hand.filter((c) => c.id === card.id).length !== 1 || transferCardBlock(g, p, card) || giftReserved(g, p.id, card.id) ) return []; const options: SaphoOption[] = []; const battle = g.battle; if (g.phase === 6 && battle?.event && battle.preLeader?.event === battle.event && !battle.preLeader.closed && !battle.preLeader.ready.includes(p.id) && !battle.revealed && Object.keys(battle.plans).length === 0 && !battle.voice && !battle.prescience && !battle.fullPlan && !battle.nexusInspection && !battle.truthPromises?.length && !g.pendingTreacheryDiscard && [battle.attacker, battle.defender].includes(p.id) && battleAggressor(g) !== p.id) options.push({scope: 'battleAggressor', event: saphoAggressorEvent(battle.event, g.turn, battle.saphoAggressor?.uses.length ?? 0), mode: 'aggressor'}); if (saphoBattleBoundary(g)) { const order = projectedBattleOrder(g)!; if (order.remaining.includes(p.id)) { if (order.current !== p.id) options.push({scope: 'battleOrder', event: order.event, mode: 'first'}); if (order.remaining.at(-1) !== p.id) options.push({scope: 'battleOrder', event: order.event, mode: 'last'}); } } const lot = g.richeseAuction; if ( g.phase === 3 && lot?.method === 'onceAround' && !lot.outcome && g.order.includes(p.id) && p.hand.length <= handLimit(p) && !lot.acted.includes(p.id) ) { const remaining = lot.order.filter((id) => !lot.acted.includes(id)); if (remaining[0] !== p.id) options.push({ scope: 'onceAround', event: lot.event, mode: 'first' }); if (remaining.at(-1) !== p.id) options.push({ scope: 'onceAround', event: lot.event, mode: 'last' }); } const queue = saphoMovementQueue(g); if ( queue && !queue.currentStarted && queue.remaining.includes(p.id) && !g.saphoMovementLast ) { if ( !queue.remaining.some((id) => saphoMovementStarted(g, getPlayer(g, id))) && !( g.advanced && queue.remaining.some((id) => getPlayer(g, id).faction === 'guild') ) && queue.current !== p.id ) options.push({ scope: 'movement', event: queue.event, mode: 'first' }); if (queue.remaining.at(-1) !== p.id) options.push({ scope: 'movement', event: queue.event, mode: 'last' }); } return options; } function playSapho(g: Game, p: Player, action: Action) { requireRule( Object.keys(action).every((key) => ['type', 'card', 'scope', 'event', 'mode'].includes(key), ), 'Choose only Juice of Sapho, one supported scope, its event and an available mode.', ); const option = saphoOptions(g, p).find( (choice) => choice.event === action.event && choice.scope === action.scope && choice.mode === action.mode, ); requireRule( option, 'This Juice of Sapho opportunity is unavailable, already completed or expired.', ); if (option.scope === 'battleAggressor') { const b = g.battle!; const next = saphoAggressorRule(() => changeSaphoAggressor(b.saphoAggressor, b.saphoAggressorEvents, b, g.turn, p.id, option.event)); const used = discard(g, p, 'richese-juice-of-sapho'); b.saphoAggressor = next; (b.saphoAggressorEvents ??= []).push(option.event); log(g, `${p.name} discarded Juice of Sapho to become this battle’s aggressor before plans. They now win ordinary ties; a Habbanya Stronghold advantage still takes precedence. Battle participants and later battle choices stay unchanged.`, {faction: p.faction, name: 'Juice of Sapho'}); if (g.semutaPreview) { const continuation: SaphoAggressorDiscardContinuation = { kind: 'saphoAggressorDiscard', player: p.id, card: used.id, battleEvent: b.event!, saphoEvent: option.event!, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = saphoAggressorDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'sapho:aggressor', entries, continuation); } } } else if (option.scope === 'onceAround') { const lot = g.richeseAuction!; // Discarding this card frees a slot even when a full hand excluded its holder // at opening. Only this still-open finite lot gains that unacted bidder. if (!lot.eligible.includes(p.id)) { lot.eligible.push(p.id); lot.order.push(p.id); lot.tieOrder = g.order.filter((id) => lot.eligible.includes(id)); } g.richeseAuction = reorderRicheseBidder( lot, option.event, p.id, option.mode, ); g.active = g.richeseAuction.active; const used = discard(g, p, 'richese-juice-of-sapho'); log( g, `${p.name} discarded Juice of Sapho to bid ${option.mode} in this Once Around auction. Completed bids and their funding remain committed; no bidder receives another bid.`, { faction: p.faction, name: 'Juice of Sapho' }, ); if (g.semutaPreview) { const continuation: SaphoAuctionDiscardContinuation = { kind: 'saphoAuctionDiscard', player: p.id, card: used.id, event: lot.event, mode: option.mode, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = saphoAuctionDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'sapho:auction', entries, continuation); return; } } settleRicheseLot(g); } else if (option.scope === 'battleOrder') { const next = battleOrderRule(() => reorderBattleChoosers({state: g.battleOrder, physicalOrder: g.order, turn: g.turn, afterBattle: battleOrderAfterBattle(g), pairs: quoteCombatBoard(g).battles, event: option.event, player: p.id, mode: option.mode})); const used = discard(g, p, 'richese-juice-of-sapho'); g.battleOrder = next; (g.battleOrderUseEvents ??= []).push(option.event); g.active = battleOrderQuote(g).current; log(g, `${p.name} discarded Juice of Sapho to choose their remaining battles ${option.mode} this Battle Phase. Other players may still choose to fight them earlier. Physical storm order and battle aggressor are unchanged.`, {faction: p.faction, name: 'Juice of Sapho'}); if (g.semutaPreview) { const continuation: SaphoBattleOrderDiscardContinuation = { kind: 'saphoBattleOrderDiscard', player: p.id, card: used.id, event: option.event!, mode: option.mode, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = saphoBattleOrderDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'sapho:battleOrder', entries, continuation); } } } else { const queue = saphoMovementQueue(g)!; const next = option.mode === 'last' ? protectOrderedOpportunityLast(queue, { event: option.event, player: p.id, }) : reorderOrderedOpportunity(queue, { event: option.event, player: p.id, position: 'first', }); g.movementRemaining = [...next.remaining]; if (option.mode === 'last') g.saphoMovementLast = { event: option.event, turn: g.turn, player: p.id }; const used = discard(g, p, 'richese-juice-of-sapho'); log( g, `${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.`, { faction: p.faction, name: 'Juice of Sapho' }, ); if (g.semutaPreview) { const continuation: SaphoMovementDiscardContinuation = { kind: 'saphoMovementDiscard', player: p.id, card: used.id, event: option.event, mode: option.mode, previousActive: g.active!, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = saphoMovementDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'sapho:movement', entries, continuation); return; } } // Preserve the Guild decision already settled for an unchanged current actor. if (g.active !== g.movementRemaining[0]) movementTurn(g); } } function movementTurn(g: Game) { const remaining = g.movementRemaining!; if (!remaining.length) { if (offerMoritaniDuke(g)) return; nextPhase(g); return; } const guild = byFaction(g, 'guild'); if ( g.advanced && guild && !g.guildTimingLocked && remaining.includes(guild.id) && guild.id !== g.saphoMovementLast?.player && remaining.some( (id) => id !== guild.id && id !== g.saphoMovementLast?.player, ) ) { g.active = null; g.decision = { kind: 'guildTiming', player: guild.id, next: remaining[0], following: remaining.find((id) => id !== guild.id)!, }; } else g.active = remaining[0]; } function chooseGuildTiming(g: Game, take: boolean) { const guild = byFaction(g, 'guild')!; if (take) g.active = guild.id; else { const rest = g.movementRemaining!.filter((id) => id !== guild.id); const protectedLast = g.saphoMovementLast?.player; rest.splice( protectedLast ? rest.indexOf(protectedLast) : rest.length, 0, guild.id, ); g.movementRemaining = rest; g.active = g.movementRemaining[0]; } } function movementPhaseQuote(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof MovementPhaseQuoteError) throw new RuleError(error.message); throw error; } } function advisorBattleOptions(g: Game) { const quote = movementPhaseQuote(() => quoteAdvisorBattleOffer(g)); applyAdvisorReleases(g, quote.released); return quote.territories; } function commitMovementPhaseStart(g: Game, quote: MovementPhaseQuote) { if (quote.kind === 'opening') { g.phaseOpening = quote.phaseOpening; return; } g.movementRemaining = quote.remaining; g.saphoMovementLast = null; g.guildTimingGranted = false; g.guildTimingLocked = false; g.spicePeekKnown = false; if (quote.spice.owner) { if (quote.spice.refill) refillSpice(g); if (quote.spice.hasCards) g.response = { kind: 'atreidesSpice', owner: quote.spice.owner, passed: [], }; } for (const p of g.players) { p.shipped = false; p.moved = 0; } applyAdvisorReleases(g, quote.released); g.decision = quote.decision; g.active = quote.active; } function beginAdvisorBattle(g: Game, remaining = advisorBattleOptions(g)) { const bg = byFaction(g, 'beneGesserit'); if (bg && remaining.length) g.decision = { kind: 'advisorBattle', player: bg.id, territories: remaining, }; else movementTurn(g); } function finishAdvisorReaction( g: Game, details: { advisorResume?: 'wormRide' | 'declaration' | 'ambassador' | 'discoveryEntry'; discoveryEntry?: DiscoveryEntryArrivalChild; advisorAmbassadorEvent?: string; advisorRemaining?: string[]; advisorFollowup?: { shipment: string; destination: string }; }, ) { const bg = byFaction(g, 'beneGesserit')!; if (details.advisorFollowup && spiritualAdvisorMaximum(g, bg.id) > 0) g.decision = { kind: 'advisor', player: bg.id, ...details.advisorFollowup }; else if (details.advisorResume === 'wormRide') nextWormRide(g); else if (details.advisorResume === 'declaration') beginAdvisorBattle(g, details.advisorRemaining ?? []); else if (details.advisorResume === 'ambassador') continueAmbassadorArrival(g, details.advisorAmbassadorEvent!); else if (details.advisorResume === 'discoveryEntry') { requireRule(details.discoveryEntry, 'The advisor reaction has lost its Discovery arrival.'); completeDiscoveryEntryArrival(g, details.discoveryEntry); } } function intrusion( g: Game, mover: Player, t: string, options: { wormRide?: boolean; discoveryEntry?: DiscoveryEntryArrivalChild; ambassadorEvent?: string; followup?: { shipment: string; destination: string }; } = {}, ) { const bg = byFaction(g, 'beneGesserit'); if (g.advanced && bg && bg.id !== mover.id && fighterCount(bg, t)) { g.decision = { kind: 'intrusion', player: bg.id, territory: t, ...options }; return true; } return false; } function assignRemainingTech(g: Game) { if (!g.techTokens) return; const unassigned = shuffle( TECH_TOKENS.filter((t) => !g.techTokens![t.id].owner), ); const recipients = g.order.filter( (id) => !ownedTech(g.techTokens, id).length, ); for (const token of unassigned) { const id = recipients.shift(); if (!id) break; g.techTokens[token.id].owner = id; log( g, `${getPlayer(g, id).name} received ${token.name} after the first storm.`, ); } } function techIncome(g: Game, id: TechId, actor: Player) { if (!g.techTokens) return; const rule = TECH_TOKENS.find((t) => t.id === id)!; const token = g.techTokens[id]; if ( g.phase !== rule.phase || actor.faction === rule.except || !token.owner || token.triggeredTurn === g.turn ) return; token.triggeredTurn = g.turn; token.spice = ownedTech(g.techTokens, token.owner).length; log( g, `${rule.name} accrued ${token.spice} spice for ${getPlayer(g, token.owner).name}, payable at phase end.`, ); } function currentPhaseResources(g: Game) { try { return quotePhaseResources(g); } catch (error) { if (error instanceof PhaseResourceError) throw new RuleError(error.message); throw error; } } function commitPhaseResources(g: Game, quote: PhaseResourceQuote) { for (const credit of quote.credits) { const owner = getPlayer(g, credit.player); owner.spice = credit.balance; if (credit.kind === 'tech') log( g, `${owner.name} collected ${credit.amount} spice from ${TECH_TOKENS.find((t) => t.id === credit.token)!.name}.`, ); } for (const id of quote.tokenResets) g.techTokens![id].spice = 0; if (quote.clearAid) g.aid = {}; } function transferTech(g: Game, id: TechId, winner: string) { g.techTokens![id].owner = winner; log( g, `${getPlayer(g, winner).name} took ${TECH_TOKENS.find((t) => t.id === id)!.name} after winning the battle.`, ); } function decideChoamMarket(g: Game, decision: Extract, action: Action) { const market = g.choamMarket!; const owner = getPlayer(g, market.owner); if (decision.kind === 'choamMarket') { if (action.done === true) { finishChoamMarket(g); } else if (action.mode === 'sell') { const sale = quoteSale( owner.hand, action.card, action.witness, market.blocked, ); requireRule( sale, 'Choose an available Worthless card or surplus exact duplicate.', ); const card = owner.hand.find((c) => c.id === sale.card)!; const blocked = homeworldRule(() => homeworldWorthlessSaleBlock(g, owner.id, card)); requireRule(!blocked, blocked ?? 'This card sale is unavailable.'); market.sale = sale; log( g, `${owner.name} revealed ${sale.witness ? 'two copies of ' : ''}${card.name} and offered one for ${sale.price} spice.`, ); g.response = { kind: 'choamSale', owner: owner.id, passed: [] }; } else { requireRule( action.mode === 'trade', 'Sell a card, offer a trade or finish the phase.', ); requireRule( g.choamTradeTurn !== g.turn, 'CHOAM may exchange one card with its ally only once per turn.', ); const ally = g.players.find( (p) => p.id === owner.ally && p.ally === owner.id, ); requireRule(ally, 'You need an ally for a two-way trade.'); requireRule( owner.hand.some((c) => c.id === action.card), 'Offer a card from your own hand.', ); market.trade = { ally: ally.id, offered: stringField(action.card) }; market.tradeAttempted = true; g.decision = { kind: 'choamTradeReply', player: ally.id }; } } else { const trade = market.trade!; const ally = getPlayer(g, trade.ally); if (action.decline === true) { delete market.trade; resumeChoamMarket(g); } else if (decision.kind === 'choamTradeReply') { requireRule( ally.hand.some((c) => c.id === action.card), 'Choose one of your cards to return, or decline.', ); trade.returned = stringField(action.card); g.decision = { kind: 'choamTradeConfirm', player: owner.id }; } else { requireRule( action.accept === true, 'Confirm the two-way exchange or decline.', ); const offered = owner.hand.find((c) => c.id === trade.offered); const returned = ally.hand.find((c) => c.id === trade.returned); if ( offered && returned && owner.ally === ally.id && ally.ally === owner.id && g.choamTradeTurn !== g.turn ) { owner.hand = owner.hand.filter((c) => c.id !== offered.id); ally.hand = ally.hand.filter((c) => c.id !== returned.id); owner.hand.push(returned); ally.hand.push(offered); g.choamTradeTurn = g.turn; log( g, `${owner.name} and ${ally.name} exchanged one treachery card each.`, ); } else log( g, 'The proposed card exchange is no longer possible; both hands remain unchanged.', ); delete market.trade; resumeChoamMarket(g); } } } function openChoamMarket(g: Game, resume: ChoamMarket['resume']) { const choam = byFaction(g, 'choam'); if (g.status !== 'playing' || !choam) return false; g.choamMarket = { owner: choam.id, resume, blocked: [] }; g.decision = { kind: 'choamMarket', player: choam.id }; return true; } function resumeChoamMarket(g: Game) { g.decision = g.biddingEnd ? null : { kind: 'choamMarket', player: g.choamMarket!.owner }; } function finishChoamMarket(g: Game) { if (g.biddingEnd || openBiddingEnd(g)) { const owner = g.choamMarket!.owner; if (!g.biddingEnd!.ready.includes(owner)) g.biddingEnd!.ready.push(owner); g.decision = null; return; } const resume = g.choamMarket!.resume; g.choamMarket = null; g.decision = null; if (resume === 'storm') advanceAfterStorm(g); else advancePhase(g); } function nextPhase(g: Game) { if (openBiddingEnd(g)) return; if (!openChoamMarket(g, 'phase')) advancePhase(g); } function openBiddingEnd(g: Game): boolean { const emperor = byFaction(g, 'emperor'); if (g.status !== 'playing' || g.phase !== 3 || !g.homeworlds?.custody || !emperor) return false; if (g.biddingEnd) return true; const choam = byFaction(g, 'choam'); g.biddingEnd = { event: crypto.randomUUID(), turn: g.turn, owners: [emperor.id, ...(choam ? [choam.id] : [])], ready: [] }; if (choam) g.choamMarket ??= { owner: choam.id, resume: 'phase', blocked: [] }; g.decision = null; g.active = null; log(g, 'Bidding is complete. Kaitain disposal and any CHOAM market actions share this closing opportunity; the owners may act in either order.'); return true; } function finishBiddingEnd(g: Game): boolean { if (!biddingEndQuiet(g)) return false; let changed = false; const end = g.biddingEnd!; for (const id of end.owners) { if (end.ready.includes(id) || !biddingEndPubliclyEmpty(g, id)) continue; end.ready.push(id); changed = true; log(g, `${getPlayer(g, id).name} has no cards and no incoming exchange is available. Their end-of-Bidding opportunity finishes automatically.`); } if (end.owners.every((id) => end.ready.includes(id))) { g.biddingEnd = null; g.choamMarket = null; advancePhase(g); return true; } return changed; } function decideBiddingEnd(g: Game, p: Player, action: Action) { const end = g.biddingEnd; requireRule(end && action.event === end.event && end.owners.includes(p.id), 'Choose the current end-of-Bidding opportunity for your faction.'); requireRule(biddingEndQuiet(g), 'Finish the pending interaction before an end-of-Bidding action.'); if (action.mode === 'ready') { requireRule(!end.ready.includes(p.id), 'You are already ready to finish Bidding.'); end.ready.push(p.id); log(g, `${p.name} is ready to finish Bidding. They may still act if another closing opportunity remains.`); } else if (action.mode === 'discard') { const quote = homeworldRule(() => quoteKaitainDiscards(g, p.id, action.cards)); requireRule(quote.cards.length > 0, 'Choose cards to discard, or finish your opportunity.'); for (const card of quote.cards) { const blocked = ambassadorDiscardBlock(g, p, card); requireRule(!blocked, blocked ?? 'This card is committed to another effect.'); } p.spice -= quote.cost; const entries = quote.cards.map((card) => ({ card: discard(g, p, card.id), discardedBy: p.id, publicFace: false })); log(g, `${p.name} paid ${quote.cost} spice to the bank and discarded ${entries.length} Treachery Card${entries.length === 1 ? '' : 's'} through high-population Kaitain. No new auction begins.`, { faction: p.faction, name: 'Kaitain disposal' }); stageTreacheryDiscard(g, 'kaitain', entries, { kind: 'kaitainDiscard', owner: p.id, event: end.event, cost: quote.cost, spiceAfter: p.spice }); } else { requireRule(p.faction === 'choam' && g.choamMarket?.owner === p.id && (action.mode === 'sell' || action.mode === 'trade'), 'Choose Kaitain disposal, a CHOAM market action or finish your opportunity.'); decideChoamMarket(g, { kind: 'choamMarket', player: p.id }, action); } } function advancePhase(g: Game) { if (g.phase === 8) requireRule(!g.moritaniExtortion, 'Settle Extortion before leaving Mentat Pause.'); if (g.phase === 6) boardResolution(() => quoteBattlePhaseAdvance(g)); commitPhaseResources(g, currentPhaseResources(g)); g.ready = []; g.active = null; const choam = byFaction(g, 'choam'); if (g.phase === 8 && choam) { g.choamMentatPending = true; g.decision = { kind: 'choamMentat', player: choam.id }; return; } const ecaz = byFaction(g, 'ecaz'); if (g.phase === 4 && ecaz && g.ecazPlacementTurn !== g.turn) { g.ecazAmbassadors ??= createAmbassadors(random); g.decision = { kind: 'ecazPlacement', player: ecaz.id }; return; } completePhase(g); } function ambassadorPlacementQuote( g: Game, owner: Player, token: string, to: string, ) { const destination = TERRITORIES.find((t) => t.id === to); requireRule(g.ecazAmbassadors, 'No Ambassador supply is available.'); try { return placeAmbassador(g.ecazAmbassadors, token, { turn: g.turn, availableSpice: owner.spice, destination: { id: to, stronghold: destination?.type === 'stronghold', allowed: !!destination, inStorm: !destination || destination.sectors.includes(g.storm), }, }); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Ambassador placement.', ); } } function finishEcazPlacement(g: Game) { g.ecazPlacementTurn = g.turn; g.pendingEcazPlacement = null; g.decision = null; completePhase(g); } function completePhase(g: Game) { if (closeNexusCards(g)) return; if (g.phase === 6) { delete g.battleOrder; delete g.battleOrderUseEvents; } if (g.phase === 7 && g.grummanCollection?.turn === g.turn && g.grummanCollection.stage === 'waiting') { g.grummanCollection.stage = 'complete'; g.grummanCollection.outcome = 'expired'; g.grummanCollection.signature = grummanCollectionSignature(g.grummanCollection); } if (g.phase === 8) requireRule(!g.moritaniExtortion, 'Settle Extortion before ending the turn.'); if (g.phase === 8) settleStrongholdOwnership(g); g.phase++; if (g.phase === 9) { if (g.dukeVidal) g.dukeVidal = expireDuke(g.dukeVidal, g.turn); observeOccupation(g, 'turnEnd'); g.turn++; g.recruits = null; delete g.guildRateBlocked; g.phase = 0; observeOccupation(g, 'turnStart'); } openPhase(g); } /** Open the same public window for every Ix table, independently of who holds Amal. */ function openPhase(g: Game, initialize = true) { if (g.phase === 1 && g.nexusCards && g.nexusCards.phase?.turn !== g.turn) g.nexusCards.phase = createNexusCardPhase(g.turn); if (initialize && g.phase === 4 && g.homeworlds) { g.homeworldRevival = homeworldRule(() => snapshotHomeworldRevival(g)); if (g.homeworldRevival?.tleilaxu.low) log(g, 'Tleilax began Revival at low population. Tleilaxu will receive no bank reward for other factions’ Free Revival this phase, even if Tleilax later returns to high population. Paid revival and Ghola income are unchanged.', { faction: 'tleilaxu', name: 'Tleilax low population' }); } if (g.expansions.includes('ix')) g.phaseOpening = { passed: [], initialize }; else if (initialize) beginPhase(g); } function beginPhase(g: Game) { if (g.phase === 0) { g.auditorInsight = null; retireNexusChoamInsight(g); } if (g.phase === 2 && g.choamCharity?.turn !== g.turn) { const choam = byFaction(g, 'choam'); if (choam && charityMultiplier(g) === 0) { g.choamCharity = { turn: g.turn, canceled: false }; log( g, 'Inflation cancels all charity this turn, including CHOAM income.', ); } else if (choam) { g.response = { kind: 'choamCharity', owner: choam.id, passed: [] }; return; } } if (g.phase === 4) { g.revivalRules = newRevivalRules(); g.revivalRequests = {}; g.freeRevival = []; g.emperorExtra = {}; for (const p of g.players) { p.revived = 0; p.freeForcesRevived = 0; p.leaderRevived = false; } } if (g.phase === 3) setAuction(g); if (g.phase === 5) { commitMovementPhaseStart( g, movementPhaseQuote(() => quoteMovementPhaseStart(g)), ); } if (g.phase === 6) { const b = battles(g); if (b.length) g.active = b[0].chooser; else { nextPhase(g); return; } } if (g.phase === 7) { collect(g); } if (g.phase === 8) { replaceMoritaniAssassinationTraitors(g); if (g.inflation && g.inflation.updatedTurn < g.turn) { if (g.inflation.flipped) { g.inflation = null; log(g, 'The Inflation token was removed from the game.'); } else { g.inflation.side = g.inflation.side === 'double' ? 'cancel' : 'double'; g.inflation.flipped = true; g.inflation.updatedTurn = g.turn; log( g, `Inflation flipped to ${g.inflation.side} for next turn’s charity.`, ); } } for (const p of g.players) { p.spice += p.bribes; p.bribes = 0; } const moritani = byFaction(g, 'moritani'); if ( moritani && g.moritaniTerror && g.moritaniTerror.placementTurn !== g.turn ) { g.decision = { kind: 'moritaniPlacement', player: moritani.id }; } else finishMoritaniPlacement(g); } if (g.phase === 0 && g.turn > 1) { if (g.discoveryEnabled && g.discoveries) { g.discoveryEntry = nexusRule(() => createDiscoveryEntryRound({...g,discoveries:g.discoveries!},crypto.randomUUID())); continueDiscoveryEntry(g); } else beginTurnBoardMovement(g); } } function beginTurnBoardMovement(g: Game) { const ixians = byFaction(g, 'ixians'); if ( g.mobileStronghold?.location && ixians && !homeworldMobileStrongholdMovementBlock(g, ixians.id) && at(ixians, MOBILE_STRONGHOLD) ) { g.decision = { kind: 'mobileStronghold', player: ixians.id, placement: false, }; return; } beginStormTurn(g); } /** Transfer actual charity only after its applicable power response. */ function payCharity(g: Game, player: Player, amount: number, homeworld = 0) { if (g.homeworlds) { const eligible = charityQuote(g, player); requireRule( eligible.total > 0 && amount === eligible.total && homeworld === eligible.homeworld, 'The saved Homeworld charity claim does not match its eligible ordinary and bank amounts.', ); } requireRule( Number.isSafeInteger(homeworld) && homeworld >= 0 && homeworld <= amount, 'The Homeworld charity payment is invalid.', ); const ordinary = amount - homeworld; const payer = charityPayer(g); // The published charity rule does not specify insolvency after intervening // spending. Fail atomically while this edge case remains under audit; never // mint a bank subsidy or create a negative spice balance silently. requireRule( !payer || payer.spice >= ordinary, 'CHOAM cannot fund this charity claim.', ); if (payer) payer.spice -= ordinary; player.spice += amount; if (homeworld) log( g, `${player.name} received ${ordinary} ordinary charity spice ${payer ? `from ${payer.name}` : 'from the Spice Bank'} and ${homeworld} additional bank spice for low Homeworld population.`, { faction: player.faction, name: 'Homeworld charity' }, ); techIncome(g, 'production', player); } function beginStormTurn(g: Game) { if (g.dukeVidal) delete g.dukeVidal.leader.usedAt; for (const p of g.players) if (p.elites) p.elites.revived = 0; g.stormDialers = g.lastBattle; if (g.advanced && byFaction(g, 'fremen')) { g.stormDialers = []; g.stormPending = g.stormCard ?? shuffle([1, 2, 3, 4, 5, 6])[0]; g.stormMovementSource = nexusRule(() => createStormSource(g.turn,'card',g.stormPending!)); g.stormCard = null; g.stormCardKnown = false; log(g, `Storm card revealed: ${g.stormPending} sectors.`, undefined, isStormCardDistance(g.stormPending) ? { kind: 'stormCard', distance: g.stormPending } : undefined); } for (const p of g.players) { for (const l of p.leaders) { delete l.usedAt; if (l.concealed) delete l.concealed.usedAt; } if (p.kwisatz) delete p.kwisatz.usedAt; } g.hajr = []; g.nexus = false; log(g, `Turn ${g.turn} begins.`); } /** Board identity excludes private balances and cards, which independent effects may change. */ function collectionBoardSignature(g: Game) { return JSON.stringify({ advanced: g.advanced, storm: g.storm, order: g.order, mobile: g.mobileStronghold ?? null, players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, forces: p.forces, advisors: p.advisors ?? null, elites: p.elites?.forces ?? null, marker: p.noField?.deployed?.location ?? null, })), }); } function collectionAllocation(work: () => T): T { try { return work(); } catch (error) { if (error instanceof SpiceAllocationError) throw new RuleError(error.message); throw error; } } function ecazCollectionIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const controls: ( | { response?: ResponseWindow | null; decision?: Decision | null; pendingKarama?: Game['pendingKarama']; } | null | undefined )[] = [ g, g.pendingExchange, g.pendingRicheseGift?.resume, g.pendingNullentropy?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null, ].filter(Boolean); const hasCollectionControl = controls.some((control) => { const karama = control!.pendingKarama; const response = control!.response?.kind === 'worthlessKarama' && karama?.use.kind === 'cancel' ? karama.use.response : control!.response; return ( control!.decision?.kind === 'ecazSpice' || response?.kind === 'ecazCollection' ); }); const pending = g.ecazCollection; if (!pending) { requireRule( !hasCollectionControl, 'The shared collection decision has lost its collection event.', ); return; } requireRule( Number.isSafeInteger(pending.turn) && pending.turn >= 1 && pending.turn <= g.turn && typeof pending.event === 'string' && pending.event.length > 0 && typeof pending.canceled === 'boolean' && Array.isArray(pending.settled), 'The saved collection event is invalid.', ); if (pending.stage === 'complete') { requireRule( pending.allocation === null && !hasCollectionControl, 'Completed collection cannot retain unresolved shared spice.', ); return; } requireRule( g.status === 'playing' && g.phase === 7 && pending.turn === g.turn && typeof pending.event === 'string' && pending.event.length > 0 && typeof pending.canceled === 'boolean' && ['response', 'allocation'].includes(pending.stage) && pending.boardSignature === collectionBoardSignature(g), 'Shared collection must retain its original turn and collecting forces.', ); const quote = boardResolution(() => quoteSpiceCollection( { ...g, players: g.players.map((p) => ({ ...p, spice: 0 })), spice: pending.sourceSpice, }, pending.canceled, ), ); if (pending.stage === 'response') { requireRule( !pending.canceled && pending.settled.length === 0 && pending.allocation === null && quote.collectionBonus && JSON.stringify(g.spice) === JSON.stringify(pending.sourceSpice), 'Ecaz Collection needs its original uncollected stronghold-income opportunity.', ); requireRule( controls.some((control) => { const karama = control!.pendingKarama; const response = control!.response?.kind === 'worthlessKarama' && karama?.use.kind === 'cancel' ? karama.use.response : control!.response; return ( response?.kind === 'ecazCollection' && response.owner === quote.collectionBonus!.owner ); }), 'The saved Ecaz Collection response has lost its decision owner.', ); } else { const allocation = pending.allocation; requireRule( allocation && JSON.stringify(allocation.lots) === JSON.stringify(quote.shared) && JSON.stringify(g.spice) === JSON.stringify(quote.spice), 'Shared collection escrow no longer matches its original desert deposits.', ); requireRule( controls.some( (control) => control!.decision?.kind === 'ecazSpice' && control!.decision.player === allocation.player, ), 'The saved shared-spice allocation has lost its decision owner.', ); requireRule( pending.settled.length === allocation.index && pending.settled.every((receipt, index) => { const lot = allocation.lots[index]; return ( receipt && lot && receipt.territory === lot.territory && receipt.ecaz === lot.ecaz && receipt.ally === lot.ally && ['agreement', 'equal'].includes(receipt.method) && Number.isSafeInteger(receipt.ecazAmount) && receipt.ecazAmount >= 0 && Number.isSafeInteger(receipt.allyAmount) && receipt.allyAmount >= 0 && receipt.ecazAmount + receipt.allyAmount === lot.amount && (receipt.method !== 'equal' || receipt.ecazAmount === Math.floor(lot.amount / 2)) ); }), 'Shared collection receipts do not match the remaining allocation.', ); collectionAllocation(() => quoteSpiceAllocation(allocation, allocation.player, { kind: 'equal' }), ); } } function currentEcazCollectionQuote( g: Game, response: ResponseWindow, canceled: boolean, ) { ecazCollectionIntegrity({ ...g, response, pendingKarama: null }); const pending = g.ecazCollection; requireRule( response.kind === 'ecazCollection' && pending?.stage === 'response' && pending.turn === g.turn, 'This Ecaz Collection response is no longer pending.', ); const quote = boardResolution(() => quoteSpiceCollection(g, canceled)); requireRule( quote.collectionBonus?.owner === response.owner, 'Ecaz Collection requires the original allied stronghold occupation.', ); return quote; } function creditGiediCollection(g: Game, player: string, desert: number) { if (!g.homeworlds?.custody || getPlayer(g, player).faction !== 'harkonnen') return; const quote = homeworldRule(() => quoteGiediCollectionReceipt( g, g.turn, player, [{ kind: 'desert', amount: desert }], g.giediCollection, )); const p = getPlayer(g, player); requireRule(Number.isSafeInteger(p.spice + quote.amount), 'Giedi Prime collection would overflow the spice balance.'); p.spice += quote.amount; g.giediCollection = quote.receipt; if (quote.amount) log(g, 'Harkonnen received 2 spice from the bank: high-population Giedi Prime rewards positive desert collection once this phase.', { faction: 'harkonnen', name: 'Giedi Prime collection' }); } /** Qualification history records facts only; disputed expiry and benefits remain separate. */ function observeOccupation(g: Game, cause: 'change' | 'turnStart' | 'turnEnd' = 'change') { if (g.homeworlds?.historyVersion !== 1 || !g.homeworldOccupationHistory) return; g.homeworldOccupationHistory = homeworldRule(() => observeHomeworldOccupation( g.homeworldOccupationHistory!, homeworldContext(g), g.homeworlds!.custody!, g.turn, cause, cause === 'change' ? `homeworld-change-${g.homeworldOccupationHistory!.sources[0].event}-${g.homeworldOccupationHistory!.sources.length}` : `homeworld-turn-${g.turn}-${cause}`)); } function homeworldHistoryIntegrity(g: Game) { const initialized = g.homeworlds?.historyVersion === 1; requireRule(initialized === (g.homeworldOccupationHistory !== undefined), 'The Homeworld qualification history has lost its initialized record.'); if (initialized) { homeworldRule(() => validateHomeworldOccupationHistory(g.homeworldOccupationHistory!, homeworldContext(g), g.turn)); const choam = byFaction(g, 'choam'); requireRule(!!choam === (g.tupileIntelligence !== undefined), 'The original Tupile intelligence ledger is missing.'); if (g.tupileIntelligence) homeworldRule(() => validateTupileIntelligenceState(g.tupileIntelligence!, g.players, g.turn)); } else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.'); } function tupileIntelligenceBlock(g: Game): string | null { if (!g.homeworlds?.custody || !g.tupileIntelligence || !g.homeworldOccupationHistory) return 'This saved game lacks the original occupation and intelligence history required for Tupile.'; if (g.status !== 'playing') return 'Tupile intelligence is available during play.'; if (g.phaseOpening || g.response || g.decision || g.truthtrance || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.battle?.revealed) return 'Finish the current response or committed action before requesting Tupile intelligence.'; return null; } function projectedTupileIntelligence(g: Game, id: string) { const owner = byFaction(g, 'choam'); if (!owner || owner.id !== id || !g.homeworlds?.custody) return null; const state = g.tupileIntelligence; const targets = homeworldRule(() => tupileIntelligenceTargets(homeworldContext(g), g.homeworlds!.custody!, id, state?.receipts.map((receipt) => receipt.faction) ?? [], tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g)))); return {owner: id, blocked: tupileIntelligenceBlock(g), targets, receipts: (state?.receipts ?? []).map(({event, target, faction, category, spice, count, turn, phase}) => ({event, target, faction, category, spice, count, turn, phase}))}; } function requestTupileIntelligence(g: Game, p: Player, action: Action) { const blocked = tupileIntelligenceBlock(g); requireRule(!blocked, blocked ?? 'Tupile intelligence is unavailable.'); requireRule(p.faction === 'choam' && g.tupileIntelligence!.owner === p.id, 'Only CHOAM may request its private Tupile intelligence.'); requireRule(Object.keys(action).every((key) => ['type', 'target', 'category'].includes(key)) && typeof action.target === 'string' && (action.category === 'weapons' || action.category === 'defenses'), 'Choose one opposing faction and either weapons or defenses.'); const request = homeworldRule(() => quoteTupileIntelligenceRequest(homeworldContext(g), g.homeworlds!.custody!, p.id, g.tupileIntelligence!.receipts.map((receipt) => receipt.faction), tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g)), action.target as string, action.category as TupileIntelligenceCategory)); const target = getPlayer(g, request.target); const answer = homeworldRule(() => quoteTupileIntelligenceAnswer(target.hand, target.spice, request.category)); g.tupileIntelligence = homeworldRule(() => appendTupileIntelligenceObservation(g.tupileIntelligence!, { event: crypto.randomUUID(), ...request, ...answer, turn: g.turn, phase: g.phase})); log(g, `${p.name} used Tupile intelligence against ${faction(target.faction).name}. The spice balance and chosen card count were recorded privately; this faction cannot be questioned again.`, {faction: 'choam', name: 'Tupile intelligence'}); } function grummanCollectionIntegrity(g: Game) { homeworldRule(() => validateGrummanCollection(g)); const frame = g.grummanCollection; const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const decisions = contexts.flatMap((context) => context?.decision?.kind === 'grummanCollection' ? [context.decision] : []); requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event) && (frame?.stage !== 'choice' || decisions.length > 0), 'The Grumman Collection choice has lost its original phase opportunity.'); } function stageGrummanCollection(g: Game) { const owner = byFaction(g, 'moritani'); if (!owner || !g.homeworlds?.custody || g.grummanCollection?.turn === g.turn) return; const frame: GrummanCollection = { event: crypto.randomUUID(), turn: g.turn, player: owner.id, stage: 'waiting', signature: '' }; frame.signature = grummanCollectionSignature(frame); g.grummanCollection = frame; } function grummanCollectionAutomatic(g: Game): boolean { const frame = g.grummanCollection; return !!(frame?.stage === 'waiting' && frame.turn === g.turn && g.phase === 7 && !g.phaseOpening && !g.decision && !g.response && !g.truthtrance && !g.pendingTreacheryDiscard && !g.pendingNullentropy && !g.pendingExchange && !g.pendingRicheseGift && !g.pendingKarama && (!g.ecazCollection || g.ecazCollection.stage === 'complete') && homeworldRule(() => quoteGrummanCollection(g, frame.player)).high); } function resumeGrummanCollection(g: Game) { if (!grummanCollectionAutomatic(g)) return; const frame = g.grummanCollection!; frame.stage = 'choice'; frame.signature = grummanCollectionSignature(frame); g.decision = { kind: 'grummanCollection', player: frame.player, event: frame.event }; } function decideGrummanCollection(g: Game, p: Player, action: Action) { const frame = g.grummanCollection; requireRule(frame?.stage === 'choice' && frame.player === p.id && frame.event === action.event, 'Choose the current Grumman Collection opportunity.'); if (action.decline === true) { requireRule(action.token === undefined && action.territory === undefined && action.mode === undefined, 'Declining Grumman does not move a token or collect spice.'); frame.outcome = 'decline'; log(g, `${p.name} declined Grumman’s optional Terror change; no bank spice was collected.`); } else { requireRule(action.mode === 'add' || action.mode === 'remove', 'Choose a Grumman token operation.'); requireRule(typeof action.token === 'string', 'Choose a physical Terror token.'); const destination = typeof action.territory === 'string' ? action.territory : ''; requireRule(action.mode !== 'add' || !!destination, 'Choose a stronghold for the added token.'); const intent = action.mode === 'remove' ? { mode: 'remove' as const, token: action.token } : { mode: 'add' as const, token: action.token, destination }; const quote = homeworldRule(() => quoteGrummanCollectionAction(g, p.id, intent)); requireRule(Number.isSafeInteger(p.spice + quote.amount), 'Grumman income would overflow the spice balance.'); g.moritaniTerror = quote.state; p.spice += quote.amount; frame.outcome = 'add'; frame.token = action.token; frame.territory = destination; log(g, `${p.name} added one hidden Terror token to ${territory(frame.territory).name}, which already held Terror, and received 4 spice from the bank. The separate Mentat placement remains available according to its own usage.`, { faction: p.faction, name: 'Grumman Collection' }); } frame.stage = 'complete'; frame.signature = grummanCollectionSignature(frame); } function projectedGrummanCollection(g: Game, player: string) { const frame = g.grummanCollection; if (frame?.stage !== 'choice') return null; const quote = homeworldRule(() => quoteGrummanCollection(g, frame.player)); return { event: frame.event, player: frame.player, blocked: quote.blocked, removeBlocked: quote.removeBlocked, tokens: player === frame.player ? quote.tokens.map((token) => ({ id: token.id, kind: token.kind })) : [], destinations: player === frame.player ? quote.destinations.map((id) => ({ id, name: territory(id).name })) : [] }; } function commitCollection( g: Game, quote: ReturnType, canceled = false, ) { let discovery: ReturnType; try { discovery = quoteDiscoveryCollection(g, quote); } catch (error) { if (error instanceof DiscoveryCollectionError) throw new RuleError(error.message); throw error; } const sourceSpice = { ...g.spice }; applyAdvisorReleases(g, quote.released); g.spice = quote.spice; for (const receipt of discovery.receipts) { const p = getPlayer(g, receipt.player); p.spice = receipt.balance; if (receipt.strongholds) log( g, `${faction(p.faction).name} received ${receipt.strongholds} spice from strongholds.`, ); if (receipt.collected) log( g, `${faction(p.faction).name} collected ${receipt.collected} spice.`, { faction: p.faction, name: 'Spice collection' }, ); } for (const effect of discovery.effects) { const p = getPlayer(g, effect.player); if (effect.kind === 'cistern') log( g, `${faction(p.faction).name} received ${effect.amount} spice from the bank for occupying Cistern.`, { faction: p.faction, name: 'Cistern' }, ); else log( g, `${faction(p.faction).name} took ${effect.amount} spice from ${faction(getPlayer(g, effect.from).faction).name} through Orgiz Processing Station.`, { faction: p.faction, name: 'Orgiz Processing Station' }, ); } const allocation = collectionAllocation(() => createSpiceAllocation(quote.shared), ); if ( quote.collectionBonus || allocation || g.ecazCollection?.turn === g.turn ) { g.ecazCollection = { turn: g.turn, event: g.ecazCollection?.turn === g.turn ? g.ecazCollection.event : crypto.randomUUID(), stage: allocation ? 'allocation' : 'complete', canceled, allocation, settled: [], sourceSpice, boardSignature: collectionBoardSignature(g), }; } if (quote.collectionBonus && !canceled) log( g, `Ecaz Collection paid both allies their full income in ${quote.collectionBonus.strongholds.map((id) => territory(id).name).join(', ')}.`, { faction: 'ecaz', name: 'Collection' }, ); if (allocation) { g.decision = { kind: 'ecazSpice', player: allocation.player }; log( g, `Ecaz and its ally collected ${quote.shared.reduce((sum, lot) => sum + lot.amount, 0)} shared desert spice. Allocate each territory by agreement, or split it equally with any odd spice going to the ally.`, ); } for (const receipt of discovery.receipts) creditGiediCollection(g, receipt.player, receipt.desert); stageGrummanCollection(g); } function collect(g: Game) { if (g.grummanCollection?.turn === g.turn) { grummanCollectionIntegrity(g); return; } if (g.ecazCollection?.turn === g.turn) { ecazCollectionIntegrity(g); return; } if (g.giediCollection?.turn === g.turn) { homeworldRule(() => validateGiediCollection(g, g.turn, g.giediCollection)); return; } const quote = boardResolution(() => quoteSpiceCollection(g)); if (quote.collectionBonus) { applyAdvisorReleases(g, quote.released); g.ecazCollection = { turn: g.turn, event: crypto.randomUUID(), stage: 'response', canceled: false, allocation: null, settled: [], sourceSpice: { ...g.spice }, boardSignature: collectionBoardSignature(g), }; g.response = { kind: 'ecazCollection', owner: quote.collectionBonus.owner, passed: [], }; return; } commitCollection(g, quote); } function decideSharedSpice(g: Game, id: string, action: Action) { ecazCollectionIntegrity(g); const pending = g.ecazCollection; requireRule( pending?.stage === 'allocation' && pending.allocation && action.event === pending.event, 'Choose the current shared-spice allocation.', ); const result = collectionAllocation(() => quoteSpiceAllocation( pending.allocation!, id, action.allocation as SpiceAllocationAction, ), ); const receipt = result.receipt; if (receipt) { for (const [owner, amount] of [ [receipt.ecaz, receipt.ecazAmount], [receipt.ally, receipt.allyAmount], ] as const) { const p = getPlayer(g, owner); requireRule( Number.isSafeInteger(p.spice) && p.spice >= 0 && Number.isSafeInteger(p.spice + amount) && p.spice + amount >= 0, 'Shared collection would overflow the spice balance.', ); } pending.settled.push(receipt); getPlayer(g, receipt.ecaz).spice += receipt.ecazAmount; getPlayer(g, receipt.ally).spice += receipt.allyAmount; creditGiediCollection(g, receipt.ecaz, receipt.ecazAmount); creditGiediCollection(g, receipt.ally, receipt.allyAmount); log( g, `${territory(receipt.territory).name} shared collection: Ecaz received ${receipt.ecazAmount} spice and ${faction(getPlayer(g, receipt.ally).faction).name} received ${receipt.allyAmount}, ${receipt.method === 'agreement' ? 'by agreement' : 'using the equal split with any odd spice going to the ally'}.`, { faction: 'ecaz', name: 'Shared collection' }, ); } else log( g, `${getPlayer(g, id).name} proposed a split of the shared spice in ${territory(pending.allocation.lots[pending.allocation.index].territory).name}. The other ally may accept, counter or use the equal split.`, ); if (result.next) { pending.allocation = result.next; g.decision = { kind: 'ecazSpice', player: result.next.player }; } else { pending.stage = 'complete'; pending.allocation = null; g.decision = null; } } function victory(g: Game, quote: VictoryQuote = currentVictoryQuote(g)) { applyAdvisorReleases(g, quote.released); g.winner = quote.winner; g.status = quote.status; if (g.winner.length) { g.status = 'finished'; const progress = strongholdProgress(g).progress; const occupy = progress.find( (row) => row.occupyTarget !== null && row.jointlyOccupied.length >= row.occupyTarget && row.members.length === g.winner.length && row.members.every((id) => g.winner.includes(id)), ); const fremen = g.turn === 10 && !progress.some((row) => row.qualifies) ? fremenSpecialVictory(g) : null; const fremenWon = fremen?.qualifies && fremen.members.length === g.winner.length && fremen.members.every((id) => g.winner.includes(id)); const specialReason = fremenWon ? ` Fremen special victory: both protected sietches meet the occupation condition and no prohibited faction occupies Tuek’s Sietch.${fremen.sietches.some((site) => site.ecazCooccupation) ? ' Allied Ecaz and Fremen may co-occupy Sietch Tabr without blocking this victory.' : ''}` : ''; log( g, `${g.winner.map((id) => faction(getPlayer(g, id).faction).name).join(' and ')} won the game.${occupy ? ` Ecaz Occupy: both allies occupy ${occupy.jointlyOccupied.map((id) => territory(id).name).join(', ')} without opposing fighters, meeting the three-stronghold alliance target.` : ''}${specialReason}`, ); } else if (g.status === 'finished') log(g, 'The tenth turn ended without a winner.'); if (quote.strongholds) settleStrongholdOwnership(g, quote.strongholds); } function cardOf(p: Player, id: string | null) { return p.hand.find((c) => c.id === id); } function inspectedPlanValue(p: Player, field: PlanField, value: string | number | null | undefined) { return battleCategoryInspectionValue(field, value, (field === 'weapon' || field === 'defense') && typeof value === 'string' ? cardOf(p, value) : undefined); } function inspectedPlanMatches(p: Player, field: PlanField, fixed: unknown, candidate: string | number | null | undefined) { return fixedBattleInspectionMatches(field, fixed as string | number | null, candidate, (field === 'weapon' || field === 'defense') && typeof candidate === 'string' ? cardOf(p, candidate) : undefined); } function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext { const b = g.battle!; const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); let blocked: string | null = null; if (!g.ecazTreachery) blocked = 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.'; else if (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id)) blocked = 'Use Harass & Withdraw in your own battle.'; else if (g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled) blocked = 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.'; else if (g.players.some(player => player.faction === 'richese') || g.richeseCache !== undefined || g.richeseRemoved !== undefined) blocked = 'Harass & Withdraw with the Richese card family awaits the Stone Burner timing ruling.'; else if (ecazAllyAtBattle(g, p, b.territory) || ecazAllyAtBattle(g, other, b.territory)) blocked = 'Harass & Withdraw with co-present Ecaz allies awaits combined-army battle integration.'; else if (p.noField?.deployed?.location.territory === b.territory) blocked = 'Reveal your No-Field before using Harass & Withdraw.'; const locations = Object.fromEntries(Object.entries(p.forces) .filter(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory) .map(([key, amount]) => [key, { normal: amount - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }])); return { blocked, forces: combatForces(g, p, b.territory, other), locations }; } function currentHarassWithdrawQuote(g: Game, p: Player, plan: Pick) { try { const context = harassWithdrawContext(g, p); const receipt = g.battle?.harassAllocation; const selection = receipt?.player === p.id && receipt.stage === 'selected' ? receipt.selection! : g.battle?.harassAllocationVersion === 1 ? defaultHarassWithdrawAllocation(context, plan.dial, plan.support) : undefined; const quote = quoteHarassWithdraw(context, plan.dial, plan.support, selection); validateReserveReturnCounters(p, quote.returned); return quote; } catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; } } function validateReserveReturnCounters(p: Player, returned: { normal: number; elite: number }, source = 'Harass & Withdraw') { requireRule(Number.isSafeInteger(p.reserves) && p.reserves >= 0 && Number.isSafeInteger(p.reserves + returned.normal + returned.elite) && (!p.elites || (Number.isSafeInteger(p.elites.reserves) && p.elites.reserves >= 0 && p.elites.reserves <= p.reserves && Number.isSafeInteger(p.elites.reserves + returned.elite))), `${source} needs valid reserve and elite counters before any forces return.`); } function harassWithdrawPreview(g: Game, p: Player): HarassWithdrawPreview | null { const b = g.battle; if (!b || ![b.attacker, b.defender].includes(p.id) || !p.hand.some(isHarassWithdraw)) return null; const context = harassWithdrawContext(g, p); return { ...context, card: HARASS_WITHDRAW_CARD, allowAllocation: b.harassAllocationVersion === 1, blocked: context.blocked ?? (b.revealed || b.plans[p.id] ? 'Your Battle Plan is already sealed.' : null) }; } function validateResidualPoison( g: Game, p: Player, cardId: string, targetId: string, event: string, ) { const b = g.battle; requireRule( g.status === 'playing' && g.phase === 6 && b && !b.revealed && [b.attacker, b.defender].includes(p.id), 'Use Residual Poison against your opponent in the current battle.', ); requireRule( b.event && b.event === event, 'This battle opportunity is stale or predates the saved card timing.', ); requireRule( targetId === (b.attacker === p.id ? b.defender : b.attacker), 'Residual Poison targets your actual opposing combatant.', ); requireRule( !Object.keys(b.plans).length && ![b.attacker, b.defender].some(owner => committedPlanElements(b, owner).some(element => element.field === 'leader')), 'Residual Poison must be played before either combatant commits a leader.', ); requireRule(!(b.nexusInspection?.mode === 'cunning' && b.nexusInspection.stage === 'answered'), 'Residual Poison after two inspected elements awaits the ruling for incompatible surviving commitments.'); requireRule( !g.truthtrance && !g.response && !g.decision && !g.phaseOpening, 'Resolve the current interaction before playing Residual Poison.', ); const card = p.hand.find((c) => c.id === cardId); requireRule( card && richeseCardDefinition(card)?.card.effect === 'residualPoison', 'Choose the canonical Residual Poison in your hand.', ); const reserved = transferCardBlock(g, p, card); requireRule(!reserved, reserved ?? 'The card is committed.'); // Guard on public configuration, never on whether a particular hidden captive exists. requireRule( !(g.advanced && byFaction(g, 'harkonnen')), 'Residual Poison with advanced Harkonnen capture awaits the secret-captive disclosure ruling.', ); const target = getPlayer(g, targetId), controlled = controlledLeaders(g, target); requireRule( !controlled.some( (l) => l.concealed || (l.capturedBy && l.gholaBy) || (l.id === DUKE_VIDAL_ID && (l.capturedBy || l.gholaBy)), ), 'This exceptional leader custody is not yet supported for Residual Poison.', ); const candidates = noFieldRule(() => residualPoisonCandidates(controlled, b.territory), ); requireRule( candidates.length > 0, 'The opposing combatant has no available physical leader for this card.', ); for (const candidate of candidates) noFieldRule(() => residualPoisonDeath(candidate)); const hand = p.hand.filter((c) => c.id !== card.id); validateTransferCompletion(g, p, p, hand, hand, { type: 'card', card: card.id, }); return { b, card, target, candidates }; } function playResidualPoison( g: Game, p: Player, cardId: string, targetId: string, event: string, ) { const { b, card, target, candidates } = validateResidualPoison( g, p, cardId, targetId, event, ); const selected = shuffle(candidates)[0]; const victim = controlledLeaders(g, target).find( (l) => l.id === selected.id, )!; const dead = residualPoisonDeath(victim); Object.assign(victim, dead); delete victim.usedAt; if (victim.capturedBy) { delete victim.capturedBy; delete victim.concealed; } if (victim.id === DUKE_VIDAL_ID && g.dukeVidal) g.dukeVidal = consumeDuke(g.dukeVidal); const harkonnen = byFaction(g, 'harkonnen'); if (harkonnen) returnCaptives(g, harkonnen); const used = discard(g, p, card.id); log( g, `${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`, { faction: p.faction, name: 'Residual Poison' }, ); if (g.semutaPreview) { const continuation: ResidualPoisonDiscardContinuation = { kind: 'residualPoisonDiscard', player: p.id, target: target.id, victim: victim.id, battleEvent: b.event!, card: used.id, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = residualPoisonDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'residualPoison', entries, continuation); return; } } reconcileChangedBattleInspections(g, 'the leader death'); if (b.preLeader && !b.preLeader.closed) b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id); } function reconcileChangedBattleInspections(g: Game, cause: string) { const b = g.battle!; // Retire an inspection made impossible by the opposing effect. Testing on // a private copy without Truthtrance avoids releasing a still-feasible truth // answer solely because an obsolete inspection was left in the trial. const insight = b.prescience; if (insight && 'value' in insight && insight.field !== 'leader') { const trial = structuredClone(g); trial.battle!.truthPromises = []; // A different impossible disclosure must not falsely retire this one. // The real extra answer is tested afterward against any retained native // commitment; the trial keeps its signed first-answer relationship. if (trial.battle!.nexusInspection?.mode === 'cunning' && trial.battle!.nexusInspection.stage === 'answered' && trial.battle!.nexusInspection.field !== 'leader') trial.battle!.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(trial),trial.battle!.nexusInspection!)); const answering = getPlayer( trial, insight.player === b.attacker ? b.defender : b.attacker, ); if (!feasiblePrescience(trial, answering, insight.field, insight.value)) { if (b.nexusInspection?.mode === 'cunning') b.nexusInspection = nexusRule(() => reopenNexusNative(battleInspectionContext(g), b.nexusInspection!)); delete insight.value; b.preparation = { kind: 'prescienceAnswer', owner: answering.id, beneficiary: insight.player, }; log( g, `The prior inspected element no longer permits a legal battle plan after ${cause}. Its owner must answer that same element again; the earlier private value is not revealed.`, ); } } const nexus = b.nexusInspection; if (nexus?.stage === 'answered' && nexus.field !== 'leader') { const trial = structuredClone(g); trial.battle!.truthPromises = []; if (!findReachableBattlePlan(trial, getPlayer(trial, nexus.target))) { b.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(g), nexus)); finishInspectionAnswers(g); log(g, `After ${cause}, the inspected element became impossible. Its owner must answer the same element again; the earlier private observation is retained.`); } } reconcileBattlePromises(g); } function residualPoisonView(g: Game, p: Player) { const card = p.hand.find( (c) => richeseCardDefinition(c)?.card.effect === 'residualPoison', ); if (!card) return null; const b = g.battle, target = b && [b.attacker, b.defender].includes(p.id) ? b.attacker === p.id ? b.defender : b.attacker : null; let blocked: string | null = null; try { validateResidualPoison(g, p, card.id, target ?? '', b?.event ?? ''); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; } return { card, event: b?.event ?? null, target, blocked }; } function controlledLeaders(g: Game, p: Player) { const native = g.players.flatMap((owner) => owner.leaders.filter((l) => controlsLeader(p, l)), ); const duke = g.dukeVidal; if ( duke && duke.controller === p.id && !duke.leader.capturedBy && !duke.leader.gholaBy && !(g.advanced && byFaction(g, 'harkonnen')) ) native.push(duke.leader); return native; } function returnCaptives(g: Game, captor: Player) { if (captor.leaders.some((l) => !l.dead)) return; let returned = 0; for (const owner of g.players) for (const l of owner.leaders) if (l.capturedBy === captor.id) { delete l.capturedBy; delete l.concealed; returned++; } if (returned) log( g, `${captor.name} returned ${returned} captive leaders because all native leaders are dead.`, ); } function captureCandidates(g: Game, loser: string, t: string) { return controlledLeaders(g, getPlayer(g, loser)).filter( (l) => !isAuditorLeader(l) && !l.dead && (!l.usedAt || l.usedAt === t), ); } function projectLeader(g: Game, l: Leader, viewer: string): Leader { const { concealed, capturedBy, ...visible } = l; const owner = g.players.find((p) => p.faction === l.faction); if ( concealed && ![concealed.controller ?? owner?.id, concealed.captor].includes(viewer) && !(getPlayer(g, viewer).faction === 'tleilaxu' && l.dead && !l.capturedBy) ) { const result = { ...visible, dead: concealed.dead, deaths: concealed.deaths, }; delete result.usedAt; if (concealed.usedAt) result.usedAt = concealed.usedAt; return result; } return { ...visible, ...(capturedBy ? { capturedBy } : {}) }; } /** Public alternatives deliberately never read a foreign No-Field token value. */ function stonePublicPools( g: Game, p: Player, opponent: Player, ): CombatForces[] { const t = g.battle!.territory; const hiddenMarker = p.noField?.deployed?.location.territory === t; const pool = combatForces( g, hiddenMarker ? { ...p, noField: undefined } : p, t, opponent, ); return hiddenMarker ? Array.from({ length: 6 }, (_, n) => ({ ...pool, normal: pool.normal + n, })) : [pool]; } function stoneTimingBlock(g: Game): string | null { return g.expansions.includes('ix') ? 'Stone Burner with the Ix expansion is awaiting a ruling on its simultaneous revealed choice with Poison Tooth. This implementation boundary applies before either hidden plan is submitted.' : null; } function stonePlanBlock( g: Game, p: Player, dial: number, support: number, ): string | null { const b = g.battle!; const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); return ( stoneTimingBlock(g) ?? stonePublicPools(g, opponent, p) .map((pool) => stoneBurnerPlanBlock( combatForces(g, p, b.territory, opponent), dial, support, pool, b.attacker === p.id ? 'attacker' : 'defender', battleTieWinner(g) === b.attacker ? 'attacker' : 'defender', ), ) .find(Boolean) ?? null ); } function stoneCompulsionBlock(g: Game, target: Player): string | null { const b = g.battle!; const other = getPlayer( g, b.attacker === target.id ? b.defender : b.attacker, ); const timing = stoneTimingBlock(g); if (timing) return timing; const opponents = stonePublicPools(g, other, target); for (const own of stonePublicPools(g, target, other)) { const supported = Array.from( { length: maxCombatDial(own) * 2 + 1 }, (_, n) => n / 2, ).some( (dial) => casualtyOptions(own, dial, 0).length && opponents.every( (opponent) => !stoneBurnerPlanBlock( own, dial, 0, opponent, b.attacker === target.id ? 'attacker' : 'defender', battleTieWinner(g) === b.attacker ? 'attacker' : 'defender', ), ), ); if (!supported) return 'Compelling Stone Burner cannot guarantee a supported zero-spice plan across the public force possibilities; the combined allocation timing is unresolved.'; } return null; } function validatePlan( g: Game, p: Player, input: Record, promises = g.battle?.truthPromises ?? [], ): Plan { const b = g.battle!; const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const forces = combatForces(g, p, b.territory, opponent); const typedForces = g.advanced || p.faction === 'ixians'; const bankerSpice = input.bankerSpice === undefined ? 0 : input.bankerSpice; requireRule(typeof bankerSpice === 'number' && Number.isSafeInteger(bankerSpice) && bankerSpice >= 0 && bankerSpice <= 3, 'Choose zero to decline, or one through three spice for Spice Banker.'); const dial = typedForces ? Number(input.dial) : integer(input.dial, 0, forces.normal + forces.elite, 'Forces dialed'); const support = g.advanced ? integer(input.support ?? 0, 0, battleSupportBudget(g, p), 'Spice support') : 0; requireRule( !typedForces || (typeof input.dial === 'number' && casualtyOptions(forces, dial, support).length > 0), 'Dial and spice must match a legal force commitment.', ); const allyPayment = integer( input.allyPayment ?? Math.max(0, battleSupportCost(g, p, support) - (p.spice - bankerSpice)), Math.max(0, battleSupportCost(g, p, support) - (p.spice - bankerSpice)), Math.min(battleSupportCost(g, p, support), battleAidFor(g, p)?.amount ?? 0), 'Battle ally payment', ); const plan: Plan = { ...(bankerSpice ? { bankerSpice } : {}), ...(allyPayment ? { allyPayment } : {}), dial, leader: typeof input.leader === 'string' && input.leader ? input.leader : null, weapon: typeof input.weapon === 'string' && input.weapon ? input.weapon : null, defense: typeof input.defense === 'string' && input.defense ? input.defense : null, support, }; requireRule( input.kwisatz === undefined || typeof input.kwisatz === 'boolean', 'Choose whether to use the Kwisatz Haderach.', ); if (input.kwisatz) { requireRule( g.advanced && p.faction === 'atreides' && p.battleLosses >= 7 && !p.kwisatz?.dead && (!p.kwisatz?.usedAt || p.kwisatz.usedAt === b.territory) && !b.kwisatzBlocked, 'Kwisatz Haderach is not available in this battle.', ); requireRule( plan.leader, 'Kwisatz Haderach must accompany a leader or Cheap Hero.', ); plan.kwisatz = true; } const available = controlledLeaders(g, p).filter( (l) => !l.dead && (!l.usedAt || l.usedAt === b.territory), ); const l = available.find((l) => l.id === plan.leader); nexusRule(() => validateSpiceBankerSpend(battleLeaderSkills(g, p), l?.id, bankerSpice, p.spice, battleSupportCost(g, p, support) - allyPayment)); if (bankerSpice) requireRule(spiceBankerModeSupported(g), 'Spice Banker with expansion modules is still being integrated.'); requireRule(!l || !g.leaderSkills?.assignments.some((assignment) => assignment.owner === p.id && assignment.leader === l.id) || !!b.leaderSkillHidden?.[p.id], 'Move your skilled leader behind the shield before using it in your battle plan.'); const hero = cardOf(p, plan.leader)?.kind === 'hero'; requireRule( !plan.leader || l || hero, 'That leader is not available for this battle.', ); requireRule( plan.leader || (!available.length && (!p.hand.some((c) => c.kind === 'hero') || (b.voice?.target === p.id && !b.voice.must && b.voice.kind === 'hero'))), 'You must use an available leader or cheap hero.', ); const w = cardOf(p, plan.weapon), d = cardOf(p, plan.defense); if (b.smugglerCollectionVersion === 1) { const smugglerPlan = { assignments: battleLeaderSkills(g, p), leader: l, weapon: w, defense: d, kwisatz: plan.kwisatz }; const block = smugglerBattlePlanBlock(smugglerPlan, smugglerBattleModeSupported(g), { territory: b.territory, spice: g.spice }); requireRule(!block, block ?? 'Smuggler collection is unavailable for this plan.'); if (smugglerPlan.assignments.some(a => a.skill === 'smuggler' && a.leader === l?.id)) nexusRule(() => smugglerBattlePile(b.territory, g.spice)); } const planetologistWeapon = canUsePlanetologistWeapon( g, p, plan.leader, w, ); requireRule( !plan.weapon || (w && battleCardSlotEligible('weapon', w, { planetologistWeapon })), 'Choose a weapon, Worthless card, eligible Planetologist Special, Harass & Withdraw, or Reinforcements.', ); requireRule( !plan.defense || (d && battleCardSlotEligible('defense', d)), 'Choose a defense, Worthless card, Harass & Withdraw, or Reinforcements.', ); requireRule( !plan.weapon || plan.weapon !== plan.defense, 'One card cannot fill both slots.', ); requireRule( plan.leader || (!plan.weapon && !plan.defense), 'Without a leader, no battle cards can be played.', ); requireRule( validBattleSlotPair(w, d, planetologistWeapon), 'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.', ); if ([w, d].some(isReinforcements)) { requireRule(![w, d].some(card => isHarassWithdraw(card) || isStoneBurner(card)), 'Reinforcements cannot share this bounded plan with Harass & Withdraw or Stone Burner.'); currentReinforcementsCost(g, p); } if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan); if (isMirrorWeapon(w)) { const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, dial, support); requireRule(!blocked, blocked ?? 'Mirror Weapon cannot resolve this commitment.'); } if (isStoneBurner(w)) { const blocked = stonePlanBlock(g, p, dial, support); requireRule( !blocked, blocked ?? 'Stone Burner cannot resolve this commitment.', ); } if (b.voice?.target === p.id) { const v = b.voice; const canPlayCards = available.length > 0 || p.hand.some((c) => c.kind === 'hero'); const has = canPlayCards && p.hand.some((c) => defaultVoiceMatch(c, v.kind)); const used = playedVoiceMatch(cardOf(p, plan.leader), 'leader', v.kind) || playedVoiceMatch(w, 'weapon', v.kind) || playedVoiceMatch(d, 'defense', v.kind); requireRule( v.must ? !has || used : !used, 'Your battle plan must comply with the Voice.', ); } for (const element of committedPlanElements(b, p.id)) requireRule( inspectedPlanMatches(p, element.field, element.value, plan[element.field]), 'Every element revealed by a battle inspection must remain unchanged.', ); requireRule( respectsBattlePromises( promises, p.id, { ...plan, kwisatz: !!plan.kwisatz }, p.hand, ), 'Your battle plan must honor your Truthtrance answers.', ); return plan; } function currentCombatResponseQuote( g: Game, operation: CombatResponseOperation, ) { try { return quoteCombatResponse( { status: g.status, phase: g.phase, advanced: g.advanced, territoryIds: combatLocations(g).map((t) => t.id), players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, specialKaramaUsed: p.specialKaramaUsed, })), battle: g.battle, }, operation, ); } catch (error) { if (error instanceof CombatResponseQuoteError) throw new RuleError(error.message); throw error; } } function commitCombatResponseQuote(g: Game, quote: CombatResponseQuote) { const b = g.battle!; if (quote.powerChecks !== undefined) b.powerChecks = quote.powerChecks; const patch = quote.patches; if (patch.eliteBlocked !== undefined) b.eliteBlocked = patch.eliteBlocked; if (patch.fremenSupportBlocked !== undefined) b.fremenSupportBlocked = patch.fremenSupportBlocked; if (patch.kwisatzBlocked !== undefined) b.kwisatzBlocked = patch.kwisatzBlocked; if (patch.choamAidBlocked !== undefined) b.choamAidBlocked = patch.choamAidBlocked; if (patch.deleteVoice) delete b.voice; if (patch.deletePrescience) delete b.prescience; if (patch.deletePreparation) delete b.preparation; if (patch.fullPlanOffered) b.fullPlanOffered = true; if (quote.response) g.response = quote.response; if (quote.decision) g.decision = quote.decision; } function nextCombatResponse(g: Game) { commitCombatResponseQuote(g, currentCombatResponseQuote(g, { kind: 'next' })); } function combatResponses(g: Game) { const b = g.battle!; b.powerChecks = []; for (const id of [b.attacker, b.defender]) { const p = getPlayer(g, id), other = getPlayer(g, id === b.attacker ? b.defender : b.attacker); if (g.advanced && p.faction === 'atreides') b.powerChecks.push({ kind: 'kwisatz', owner: id }); if ( ((g.advanced && ['emperor', 'fremen'].includes(p.faction)) || (!g.advanced && p.faction === 'ixians')) && !(p.faction === 'emperor' && other.faction === 'fremen') && combatForces(g, p, b.territory, other).elite > 0 ) b.powerChecks.push({ kind: 'eliteStrength', owner: id }); if (g.advanced && p.faction === 'fremen') b.powerChecks.push({ kind: 'fremenSupport', owner: id }); } const choam = byFaction(g, 'choam'); if ( g.advanced && choam?.ally && [b.attacker, b.defender].includes(choam.ally) && getPlayer(g, choam.ally).ally === choam.id ) b.powerChecks.push({ kind: 'choamBattleAid', owner: choam.id }); nextCombatResponse(g); } function finishBattlePreparation(g: Game) { commitCombatResponseQuote( g, currentCombatResponseQuote(g, { kind: 'finishPreparation' }), ); } function battlePreparation(g: Game, kind: 'voice' | 'prescience') { const b = g.battle!; const owner = byFaction(g, kind === 'voice' ? 'beneGesserit' : 'atreides'); const combatants = [b.attacker, b.defender]; const beneficiary = owner && (combatants.includes(owner.id) ? owner.id : owner.ally && combatants.includes(owner.ally) ? owner.ally : null); if (owner && beneficiary) b.preparation = { kind, owner: owner.id, beneficiary }; else if (kind === 'voice') battlePreparation(g, 'prescience'); else finishBattlePreparation(g); } function normalizeBattle(g: Game) { const b = g.battle; if (!b || b.prepared || b.revealed) return; b.prepared = true; if (b.prescience && !('value' in b.prescience)) { const target = b.prescience.player === b.attacker ? b.defender : b.attacker; const priorPlan = b.plans[target]; if (priorPlan) b.prescience.value = inspectedPlanValue(getPlayer(g, target), b.prescience.field, priorPlan[b.prescience.field]) as string | number | null; else b.preparation = { kind: 'prescienceAnswer', owner: target, beneficiary: b.prescience.player, }; } else if (!Object.keys(b.plans).length) battlePreparation(g, b.voice ? 'prescience' : 'voice'); } /** Ghola follows current control for ordinary leaders, but Duke's printed * revival right belongs exclusively to Ecaz, regardless of temporary custody. */ function gholaLeaders(g: Game, p: Player) { const native = controlledLeaders(g, p).filter( (l) => l.id !== DUKE_VIDAL_ID && l.dead && !l.capturedBy, ); if (p.faction === 'ecaz' && !ecazDukeRevivalBlock(g, p.id)) native.push(g.dukeVidal!.leader); return native; } function gholaOptions(g: Game, p: Player) { const cards = p.hand .filter( (c) => c.effect === 'ghola' && !( g.richeseAuction?.source === 'blackMarket' && !g.currentAuctionSale && g.richeseAuction.owner === p.id && g.richeseAuction.cardId === c.id ), ) .map((c) => c.id); const card = p.hand.find((c) => cards.includes(c.id)); const timing = card ? ordinaryCardAvailability( { ...g, me: p.id, players: [p], }, card.id, ) : null; const leaders = gholaLeaders(g, p).map(({ id, name, strength }) => ({ id, name, strength, })); const kwisatz = !!(g.advanced && p.faction === 'atreides' && p.kwisatz?.dead); const eliteBlock = homeworldSardaukarGholaBlock(g, p.id); const eliteRemaining = eliteBlock ? 0 : Math.min( p.elites?.tanks ?? 0, eliteRevivalRemaining(p, g.advanced), ); const maxForces = Math.min( 5, p.tanks - (p.elites?.tanks ?? 0) + eliteRemaining, ); const reason = !card ? 'No Ghola card is available in your hand.' : g.pendingTreacheryDiscard ? 'Finish the current card disposal first.' : g.pendingNullentropy ? 'Finish the paid Nullentropy Box search first.' : timing && !timing.available ? timing.reason : !leaders.length && !kwisatz && maxForces <= 0 ? 'No eligible leader or force is available in your Tanks.' : null; return { cards, leaders, kwisatz, maxForces, eliteRemaining, eliteBlock, available: reason === null, reason, }; } function atomicsIntegrity(g: Game) { const aftermath = g.moritaniAtomics; if (!aftermath) { requireRule(g.players.every(player => player.atomicsHandLimitPenalty === undefined), 'Atomics hand-limit penalties require the physical Aftermath.'); return; } const owner = byFaction(g, 'moritani'); const token = g.moritaniTerror?.tokens.find(row => row.kind === 'atomics'); requireRule(atomicsModeSupported(g) && owner?.id === aftermath.moritaniId && Number.isSafeInteger(aftermath.turn) && aftermath.turn >= 1 && aftermath.turn <= g.turn && TERROR_STRONGHOLDS.includes(aftermath.territory) && token?.status === 'removed' && token.location === null && aftermath.alliancePolicy === 'unresolved' && atomicsAllianceStatus(aftermath, owner.ally) === 'activation-alliance' && g.players.every(player => player.atomicsHandLimitPenalty === (player.id === owner.id || player.id === aftermath.allyAtActivation ? true : undefined)), 'Atomics Aftermath has lost its revealed token, stable alliance or hand-limit custody.'); } function marketGholaIntegrity(g: Game) { battleOrderIntegrity(g); saphoAggressorIntegrity(g); nexusCardsIntegrity(g); nexusEmperorSecretIntegrity(g); nexusEmperorBetrayalIntegrity(g); nexusFremenBetrayalIntegrity(g); nexusBgBetrayalIntegrity(g); nexusFremenRevivalIntegrity(g); nexusFremenCunningIntegrity(g); nexusEcazBetrayalIntegrity(g); nexusTraitorIntegrity(g); nexusFaceDancerIntegrity(g); nexusSuboidIntegrity(g); nexusAdvisorIntegrity(g); nexusSardaukarIntegrity(g); nexusChoamIntegrity(g); nexusMoritaniIntegrity(g); nexusGuildSecretIntegrity(g); nexusRicheseIntegrity(g); richesePairIntegrity(g); nexusGuildCunningIntegrity(g); traitorDeclarationIntegrity(g); nexusInspectionIntegrity(g); homeworldHistoryIntegrity(g); grummanCollectionIntegrity(g); homeworldVictoryReturnIntegrity(g); homeworldRevivalReturnIntegrity(g); atomicsIntegrity(g); terrorEntryIntegrity(g); homeworldRule(() => validateGiediCollection(g, g.turn, g.giediCollection)); currentFactionPayment(g); battleCardRolesIntegrity(g); winnerDiscardsIntegrity(g); const endError = biddingEndError(g); requireRule(!endError, endError ?? 'Invalid end-of-Bidding opportunity.'); const error = choamMarketGholaError(g); requireRule(!error, error ?? 'Invalid Ghola market interruption.'); } function resumeMarketGhola(g: Game) { if (g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete') return; const pending = g.pendingChoamMarketGhola; if (!pending || pending.stage !== 'complete' || g.pendingTreacheryDiscard || g.pendingNullentropy || g.leaderSkills?.offers[pending.player] || g.response || g.decision || g.pendingKarama || g.truthtrance || g.phaseOpening || g.pendingRicheseGift || g.pendingExchange) return; nexusChoamTradeIntegrity(g); marketGholaIntegrity(g); g.response = structuredClone(pending.response); g.pendingChoamMarketGhola = null; } function playMarketGhola(g: Game, p: Player, action: Action) { requireRule(choamSaleGholaTiming(g) && !g.pendingChoamMarketGhola, 'Finish the current Ghola interruption first.'); const card = p.hand.find((c) => c.id === action.card && c.effect === 'ghola'); requireRule(card && !action.mode, 'Choose an owned Ghola card.'); const options = gholaOptions(g, p); requireRule(options.available, options.reason ?? 'Ghola is unavailable.'); requireRule(!g.pendingKarama && !g.pendingRicheseGift && !g.pendingExchange, 'Finish the nested exchange before playing Ghola.'); const reserved = ambassadorDiscardBlock(g, p, card); requireRule(!reserved, reserved ?? 'This Ghola is already committed.'); choamSaleCancellationQuote(g, g.response!); g.pendingChoamMarketGhola = { turn: g.turn, phase: g.phase, player: p.id, card: card.id, discardSequence: (g.treacheryDiscardSequence ?? 0) + 1, event: '', stage: 'discard', response: structuredClone(g.response!), market: structuredClone(g.choamMarket!), }; g.pendingChoamMarketGhola.event = choamGholaEvent(g.pendingChoamMarketGhola); g.response = null; applyGholaEffect(g, p, action, card.id); if (g.leaderSkills) { const pending = g.pendingChoamMarketGhola; const offer = g.leaderSkills.offers[p.id]; pending.leaderSkill = { leader: p.leaders.find(leader => leader.id === action.leader)?.id ?? null, offer: offer?.event ?? null, completed: !offer, signature: '', }; pending.leaderSkill.signature = choamGholaSkillSignature(pending); } if (g.response) (g.response as ResponseWindow).intent = g.pendingChoamMarketGhola.event; const used = discard(g, p, card.id); log(g, `${p.name} played ${card.name}. The declared CHOAM sale waits for this revival and its income to finish.`, { faction: p.faction, name: 'Ghola revival' }); stageOrdinaryCardDiscard(g, p, used); } function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) { if (action.leader === 'kwisatz') { requireRule( g.advanced && p.faction === 'atreides' && p.kwisatz?.dead, 'Kwisatz Haderach is not in the tanks.', ); p.kwisatz.dead = false; delete p.kwisatz.usedAt; p.kwisatz.revivalCycle = Math.max(p.revivalCycle, p.kwisatz.revivalCycle ?? 1) + 1; log( g, `${p.name} revived Kwisatz Haderach with Ghola for use again this turn. No spice or normal leader-revival allowance was spent.`, ); } else if (action.leader) { requireLeaderSkillRevival(g,p.id); requireRule( action.leader !== DUKE_VIDAL_ID || p.faction === 'ecaz', 'Only Ecaz may revive Duke Vidal, including with Ghola.', ); const l = gholaLeaders(g, p).find((l) => l.id === action.leader); requireRule(l?.dead && !l.capturedBy, 'Choose a dead leader in your pool.'); l.dead = false; delete l.concealed; // Ghola permits a return in another battle this turn (GF9 FAQ p.9). delete l.usedAt; if (l.id === DUKE_VIDAL_ID) g.dukeVidal = consumeDuke(g.dukeVidal!); if (g.pendingChoamMarketGhola && g.leaderSkills && !isAuditorLeader(l) && !l.gholaBy && p.leaders.some(leader => leader.id === l.id)) g.leaderSkills = skillRule(() => offerRevivedLeaderSkill( g.leaderSkills!, p.id, l.id, choamGholaSkillOfferEvent(g.pendingChoamMarketGhola!, l.id))); log( g, `${p.name} revived ${l.name} with Ghola.${l.id === DUKE_VIDAL_ID ? ' The living shared disc is set aside, unclaimed.' : ' The leader may fight again this turn;'} No spice or normal leader-revival allowance was spent.`, ); } else { const n = integer( action.amount ?? Math.min(5, p.tanks), 1, Math.min(5, p.tanks), 'Forces', ); const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite); const eliteBlock = elite > 0 ? homeworldSardaukarGholaBlock(g, p.id) : null; requireRule(!eliteBlock, eliteBlock ?? 'Choose an eligible Ghola return.'); requireRule( elite <= eliteRevivalRemaining(p, g.advanced), 'Only one elite force may be revived per turn.', ); const group = { amount: n, elite, free: 0 }; const grant = requireHomeworldRevivalGrant(g, p, 'ghola', group); addRevivedReserves(g, p, n, elite); p.tanks -= n; if (p.elites) { p.elites.tanks -= elite; p.elites.revived += elite; } log( g, `${p.name} revived ${n} forces${elite ? `, including ${elite} elite force${elite === 1 ? '' : 's'}` : ''} with Ghola. They returned to reserves for free without using the normal force-revival allowance.`, ); if (cardId) stageHomeworldRevivalReturn(g, p, 'ghola', group, grant, cardId); } techIncome(g, 'axlotl', p); collectRevivalIncome(g, p, 0, false, true); } /** Search complete legal plans without learning or consulting the opponent's sealed choices. */ function findLegalBattlePlan( g: Game, p: Player, options: { promises?: BattlePromise[]; prescience?: { field: PlanField; value: unknown }; } = {}, ): Plan | null { const b = g.battle!; const promises = options.promises ?? b.truthPromises ?? []; const fixed = options.prescience; const commitments = committedPlanElements(b, p.id); const accepts = (plan: Partial) => commitments.every(element => inspectedPlanMatches(p, element.field, element.value, plan[element.field])) && respectsBattlePromises(promises, p.id, plan, p.hand) && (!fixed || inspectedPlanMatches(p, fixed.field, fixed.value, plan[fixed.field])); if (b.plans[p.id]) { const sealed = { ...b.plans[p.id], kwisatz: !!b.plans[p.id].kwisatz }; return accepts(sealed) ? sealed : null; } const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker); const forces = combatForces(g, p, b.territory, opponent); const typed = g.advanced || p.faction === 'ixians'; const maxDial = typed ? maxCombatDial(forces) : forces.normal + forces.elite; const numerical: { dial: number; support: number }[] = []; for (let dial = 0; dial <= maxDial; dial += typed ? 0.5 : 1) for ( let support = 0; support <= (g.advanced && !forces.freeSupport ? Math.min(battleSupportBudget(g, p), maxCombatSupport(forces)) : 0); support++ ) { const n = { dial, support }; if ( accepts(n) && (!typed || casualtyOptions(forces, dial, support).length) ) numerical.push(n); } if (!numerical.length) return null; const leaderIds = [ null, ...controlledLeaders(g, p) .filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory)) .map((l) => l.id), ...p.hand.filter((c) => c.kind === 'hero').map((c) => c.id), ]; const weapons = [ null, ...p.hand .filter( (card) => battleCardSlotEligible('weapon', card, { planetologistWeapon: true }), ) .map((card) => card.id), ]; const defenses = [ null, ...p.hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id), ]; for (const leader of leaderIds) { if (!accepts({ leader })) continue; for (const weapon of weapons) { if (!accepts({ leader, weapon })) continue; for (const defense of defenses) { if ( !accepts({ leader, weapon, defense }) || !validBattleSlotPair( cardOf(p, weapon), cardOf(p, defense), canUsePlanetologistWeapon(g, p, leader, cardOf(p, weapon)), ) ) continue; for (const kwisatz of [false, true]) { const partial = { leader, weapon, defense, kwisatz }; if (!accepts(partial)) continue; for (const n of numerical) { const candidate = { ...partial, ...n }; if (!accepts(candidate)) continue; try { return validatePlan(g, p, candidate, promises); } catch (error) { if (!(error instanceof RuleError)) throw error; } } } } } } return null; } type BattleCompletion = { plan: Plan; actions: Action[]; waitingForIncome: boolean; }; function gholaPreparationActions(g: Game, p: Player): Action[] { const card = p.hand.find((c) => c.effect === 'ghola'); if (!card) return []; const actions: Action[] = gholaLeaders(g, p) .sort((a, b) => b.strength - a.strength) .map((l) => ({ type: 'card', card: card.id, leader: l.id })); if (g.advanced && p.faction === 'atreides' && p.kwisatz?.dead) actions.push({ type: 'card', card: card.id, leader: 'kwisatz' }); for (let amount = 1; amount <= Math.min(5, p.tanks); amount++) for ( let elite = Math.max(0, amount - (p.tanks - (p.elites?.tanks ?? 0))); elite <= Math.min( amount, p.elites?.tanks ?? 0, eliteRevivalRemaining(p, g.advanced), ); elite++ ) actions.push({ type: 'card', card: card.id, amount, elite }); return actions; } function cashInPreparationActions(g: Game, p: Player): Action[] { if (p.faction !== 'choam' || !g.advanced || p.specialKaramaUsed) return []; const available = cashInCards(g, p); const actions: Action[] = []; for (const card of available.filter((c) => canUseAsKaramaRole(g, p, c))) { const others = available.filter((c) => c.id !== card.id); for (let mask = 1; mask < 2 ** others.length; mask++) actions.push({ type: 'card', mode: 'special', card: card.id, cards: others.filter((_, i) => mask & (2 ** i)).map((c) => c.id), }); } return actions; } /** Pending income can still be allowed; it is never counted as already paid. */ function pendingBattleRevivalIncome(g: Game, p: Player): number { const r = g.response?.kind === 'revivalIncome' ? g.response : g.pendingKarama?.use.kind === 'cancel' ? g.pendingKarama.use.response : null; return r?.kind === 'revivalIncome' && r.owner === p.id ? (r.amount ?? 0) : 0; } function findReachableBattlePlan( g: Game, p: Player, options: Parameters[2] = {}, ): BattleCompletion | null { const immediate = findLegalBattlePlan(g, p, options); if (immediate) return { plan: immediate, actions: [], waitingForIncome: false }; if (g.battle!.revealed || g.battle!.plans[p.id]) return null; const initial = structuredClone(g); const player = getPlayer(initial, p.id); const pending = pendingBattleRevivalIncome(initial, player); if (pending) player.spice += pending; // The real table must finish its current window before it can execute a preparation action. if (initial.battle?.nexusInspection?.stage === 'response') initial.battle.nexusInspection = allowNexusInspection(battleInspectionContext(initial), initial.battle.nexusInspection); if (currentNexusSardaukar(initial)?.stage === 'pending') finishNexusSardaukar(initial,false); initial.response = null; initial.pendingKarama = null; // Search after Truthtrance resolves, retaining every existing or proposed // battle promise. This does not release a promise on the real table. initial.truthtrance = null; const queue: { state: Game; actions: Action[] }[] = [ { state: initial, actions: [] }, ]; for (let index = 0; index < queue.length; index++) { const node = queue[index], actor = getPlayer(node.state, p.id); if (node.actions.length || pending) { const plan = findLegalBattlePlan(node.state, actor, options); if (plan) return { plan, actions: node.actions, waitingForIncome: pending > 0 }; } for (const action of [ ...(node.state.status === 'playing' && node.state.phase === 6 && actor.faction === 'ixians' && !actor.ally && node.state.nexusCards?.cards?.hands[actor.id] === 'ixians' && node.state.nexusCards.phase?.stage !== 'drawing' && !node.state.pendingNullentropy && !node.state.pendingTreacheryDiscard && !pendingNexusTraitors(node.state) && !nexusSuboidsActive(node.state, node.state.nexusSuboidHistory, actor.id) ? [{ type: 'nexusSuboids', event: JSON.stringify(['nexusSuboids', node.state.turn, node.state.battle!.event, actor.id]) } as Action] : []), ...(actor.faction === 'emperor' && !nexusSardaukarEligibility(node.state,actor) && node.state.nexusCards?.phase?.stage !== 'drawing' && !node.state.pendingNullentropy && !node.state.pendingTreacheryDiscard && !pendingNexusTraitors(node.state) ? [{type:'nexusSardaukar',event:JSON.stringify(['nexusSardaukar',node.state.turn,node.state.battle!.event,actor.id])} as Action] : []), ...gholaPreparationActions(node.state, actor), ...cashInPreparationActions(node.state, actor), ]) { const trial = structuredClone(node.state), target = getPlayer(trial, p.id); try { if (action.type === 'nexusSuboids') commitNexusSuboids(trial, target); else if (action.type === 'nexusSardaukar') commitNexusSardaukar(trial,target,'active'); else if (action.mode === 'special') specialKarama(trial, target, action); else { applyGholaEffect(trial, target, action); discard(trial, target, action.card as string); } // Cancellation remains a real response later; a possible allowed branch proves reachability. target.spice += pendingBattleRevivalIncome(trial, target); trial.response = null; trial.pendingKarama = null; queue.push({ state: trial, actions: [...node.actions, action] }); } catch (error) { if (!(error instanceof RuleError)) throw error; } } } return null; } function shipmentPromiseIntegrity(g: Game) { try { validateSavedTruthtrance(g); } catch (error) { if (error instanceof TruthError) throw new RuleError(error.message); throw error; } const promises = g.shipmentPromises; requireRule( promises === undefined || Array.isArray(promises), 'The saved shipment promises are invalid.', ); for (const promise of promises ?? []) { requireRule( promise && typeof promise === 'object' && Number.isSafeInteger(promise.turn) && promise.turn >= 1 && promise.turn <= g.turn && g.players.some((p) => p.id === promise.player) && g.players.some( (p) => p.id === promise.asker && p.id !== promise.player, ) && validShipmentClaim(promise) && typeof promise.answer === 'boolean' && (promise.released === undefined || typeof promise.released === 'boolean') && (promise.fulfilled === undefined || typeof promise.fulfilled === 'boolean') && !(promise.released && promise.fulfilled), 'The saved shipment promise is invalid; restore its table state before continuing.', ); if (promise.turn === g.turn && !promise.released && !promise.fulfilled) requireRule( shipmentPromiseModeSupported(g) && g.status === 'playing' && g.phase === 5 && g.active === promise.player && shipmentAvailable(g,getPlayer(g,promise.player)), 'The saved shipment promise does not belong to a supported unused shipment opportunity.', ); } } /** A pending conversion may succeed. Resolve only its shipment effect in the * private reachability copy; the live table retains every opponent response. */ function projectPendingShipmentKarama(g: Game, player: string) { const pending = g.pendingKarama; if (g.response?.kind !== 'worthlessKarama' || !pending || g.response.owner !== pending.owner || pending.use.kind !== 'shipment' || pending.use.recipient !== player) return; g.response = null; g.pendingKarama = null; completeKarama(g, getPlayer(g, pending.owner), pending.use); } /** Search owned preparation and canonical shipment quotes, never the AI's scored shortlist. */ function findShipmentCompletion( state: Game, owner: Player, promises = liveShipmentPromises( state.shipmentPromises ?? [], owner.id, state.turn, ), ): { actions: Action[] } | null { if ( state.status !== 'playing' || state.phase !== 5 || state.active !== owner.id || !shipmentAvailable(state,owner) ) return null; if (promises.every(p => matchesShipment(p, null) === p.answer)) return { actions: [{ type: 'endMovement' }] }; const initial = structuredClone(state); initial.truthtrance = null; projectPendingShipmentKarama(initial, owner.id); // Test choices after the interrupt, while leaving the real continuation intact. // Every actual action still revalidates its timing, quote and physical custody. initial.response = null; initial.decision = null; initial.phaseOpening = null; initial.shipmentPromises = []; const queue: { game: Game; actions: Action[] }[] = [ { game: initial, actions: [] }, ]; const visited = new Set(); const destinations = [...new Set(promises.flatMap(shipmentClaimDestinations))]; for (let index = 0; index < queue.length; index++) { const { game: g, actions } = queue[index]; const p = getPlayer(g, owner.id); const signature = JSON.stringify([ p.spice, p.reserves, p.tanks, p.elites, g.homeworlds, p.hand.map((c) => c.id), g.aid, g.karamaShipping, ]); if (visited.has(signature)) continue; visited.add(signature); for (const destination of destinations) for (let amount = 1; amount <= p.reserves; amount++) { if ( !promises.every( (p) => matchesShipment(p, { territory: destination, amount }) === p.answer, ) ) continue; for (const sector of territory(destination).sectors) { const offer = nexusRicheseOffer(g,p.id), guildOffer = nexusGuildSecretAllyOffer(g,p.id); for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : []), ...(guildOffer && !guildOffer.blocked ? [guildOffer.event] : [])]) { const guildSecretEvent = nexus && nexus === guildOffer?.event ? nexus : undefined; const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)}; const smuggler = nexus ? null : quoteSmugglerShipment(g, p.id, destination, amount); const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(destination).type,amount)).cost : nexus ? nexusRule(() => quoteNexusRicheseShipment(price,territory(destination).type,amount)).cost : reserveShipmentCost(price,territory(destination).type,amount - (smuggler ? 1 : 0)); try { const allyPayment = contribution(g, p, cost); const elite = eliteChoice( amount, p.reserves, p.elites?.reserves ?? 0, undefined, ); const sources = g.homeworlds?.custody ? nativeReserveSources( homeworldContext(g), g.homeworlds.custody, p.id, { normal: amount - elite, elite }, ) : null; const nativeSources = sources ? { homeworldSources: sources } : {}; const shipment: PendingShipment = { ...(smuggler ? { smuggler } : {}), ...(guildSecretEvent ? {guildSecretEvent} : nexus ? {nexusEvent:nexus} : {}), ...nativeSources, turn: g.turn, player: p.id, territory: destination, sector, amount, elite, cost, allyPayment, advisors: arrivalAsAdvisor(g, p, destination), }; const trial = nexus ? structuredClone(g) : g; if (nexus && !guildSecretEvent) recordNexusRicheseShipment(trial,shipment); validatePhysicalShipment(trial,shipment); return { actions: [ ...actions, { type: 'ship', ...(smuggler ? { smuggler: true } : {}), ...(nexus ? {nexus} : {}), territory: destination, sector, amount, elite, allyPayment, ...nativeSources, }, ], }; } catch (error) { if (!(error instanceof RuleError)) throw error; } } if (p.faction === 'fremen' && byFaction(g, 'guild')?.id === p.ally) { const action: Action = { type: 'guildShip', from: 'reserves', territory: destination, sector, amount, }; try { applyActionInner(g, p.id, action); return { actions: [...actions, action] }; } catch (error) { if (!(error instanceof RuleError)) throw error; } } } } // Preparations use only owned cards, recoverable escrow and already-pending // conversion effects. More reserves/lower cost cannot remove // a previously available choice of physical shipment count. const preparations: Action[] = []; if (g.aid[p.id]?.amount > 0) preparations.push({ type: 'pledgeAid', amount: 0 }); const karama = p.hand.find((c) => canUseAsKaramaRole(g, p, c)); if (karama && !g.karamaShipping) preparations.push({ type: 'card', card: karama.id, mode: 'shipment', target: p.id, }); const ghola = gholaOptions(g, p); if (ghola.available && ghola.maxForces > 0) preparations.push({ type: 'card', card: ghola.cards[0], amount: ghola.maxForces, elite: Math.max(0, ghola.maxForces - (p.tanks - (p.elites?.tanks ?? 0))), }); for (const action of preparations) { try { const next = applyActionInner(g, p.id, action, 'shipmentPreparation'); projectPendingShipmentKarama(next, p.id); queue.push({ game: next, actions: [...actions, action] }); } catch (error) { if (!(error instanceof RuleError)) throw error; } } } return null; } function shipmentTruthAnswers( g: Game, p: Player, claim: ShipmentClaim, ): TruthAnswer[] { const prior = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn); const answers: TruthAnswer[] = []; for (const answer of [true, false]) { const promises = [ ...prior, { ...claim, turn: g.turn, player: p.id, asker: '', answer }, ]; if (findShipmentCompletion(g, p, promises)) answers.push(answer ? 'yes' : 'no'); } return answers.length ? answers : ['unknown']; } function bindShipmentTruth( g: Game, p: Player, claim: ShipmentClaim, answer: boolean, asker: string, ) { (g.shipmentPromises ??= []).push({ turn: g.turn, player: p.id, asker, ...parseShipmentClaim(claim), answer, }); } function checkShipmentPromises( g: Game, p: Player, shipment: { territory: string; amount: number } | null, ) { requireRule( liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).every( (promise) => matchesShipment(promise, shipment) === promise.answer, ), 'Honor your Truthtrance shipment answer before using this shipment opportunity.', ); } function finishShipmentPromises( g: Game, p: Player, shipment: { territory: string; amount: number } | null, ) { checkShipmentPromises(g, p, shipment); for (const promise of liveShipmentPromises( g.shipmentPromises ?? [], p.id, g.turn, )) promise.fulfilled = true; } function reconcileShipmentPromises( g: Game, voluntary?: { actor: string; action: Action }, ) { const kept: ShipmentPromise[] = []; for (const promise of g.shipmentPromises ?? []) { if (promise.turn !== g.turn || promise.released || promise.fulfilled) continue; const p = getPlayer(g, promise.player); if ( findShipmentCompletion(g, p, [ ...kept.filter((old) => old.player === p.id), promise, ]) ) kept.push(promise); else { requireRule( !voluntary || voluntary.actor !== p.id || !['card', 'bribe', 'pledgeAid'].includes(voluntary.action.type), 'You cannot voluntarily make your Truthtrance shipment answer impossible.', ); promise.released = true; log( g, `${p.name} can no longer fulfill a Truthtrance shipment answer; that answer is no longer binding.`, ); } } } function battleTruthAnswers( g: Game, p: Player, claim: PlanClaim, ): TruthAnswer[] { const promises = g.battle?.truthPromises ?? []; const answers: TruthAnswer[] = []; for (const answer of [true, false]) if ( findReachableBattlePlan(g, p, { promises: [...promises, { player: p.id, asker: '', claim, answer }], }) ) answers.push(answer ? 'yes' : 'no'); return answers.length ? answers : ['unknown']; } function bindBattleTruth( g: Game, p: Player, claim: PlanClaim, answer: boolean, asker: string, ) { if (g.battle && !g.battle.revealed && !g.battle.plans[p.id]) (g.battle.truthPromises ??= []).push({ player: p.id, asker, claim, answer, }); } function reconcileBattlePromises( g: Game, voluntary?: { actor: string; action: Action }, ) { const b = g.battle; if ( !b || b.revealed || !b.truthPromises?.length || g.response?.kind === 'voice' ) return; const kept: BattlePromise[] = []; for (const promise of b.truthPromises) { if (promise.released || b.plans[promise.player]) { kept.push(promise); continue; } const p = getPlayer(g, promise.player); if (findReachableBattlePlan(g, p, { promises: [...kept, promise] })) kept.push(promise); else { requireRule( !voluntary || p.id !== voluntary.actor || !['card', 'bribe', 'pledgeAid', 'richeseGift'].includes( voluntary.action.type, ), 'You cannot voluntarily make your Truthtrance battle promise impossible.', ); promise.released = true; log( g, `${p.name} can no longer fulfill a Truthtrance battle answer; that answer is no longer binding.`, ); } } } function feasiblePrescience( g: Game, p: Player, field: PlanField, value: unknown, ) { if ( g.battle?.truthPromises?.some( (promise) => !promise.released && promise.player === p.id, ) ) return !!findReachableBattlePlan(g, p, { prescience: { field, value } }); // Validate the revealed element without committing any other plan components. // A legal completion must exist, including compliance with the preceding Voice. const b = g.battle!; const leaderIds: (string | null)[] = [ null, ...controlledLeaders(g, p) .filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory)) .map((l) => l.id), ...p.hand.filter((c) => c.kind === 'hero').map((c) => c.id), ]; const weapons: (string | null)[] = [ null, ...p.hand .filter( (card) => battleCardSlotEligible('weapon', card, { planetologistWeapon: true }), ) .map((card) => card.id), ]; const defenses: (string | null)[] = [ null, ...p.hand.filter((c) => battleCardSlotEligible('defense', c)).map((c) => c.id), ]; for (const leader of field === 'leader' ? [value] : leaderIds) for (const weapon of field === 'weapon' ? weapons.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : weapons) for (const defense of field === 'defense' ? defenses.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : defenses) { const maxSupport = g.advanced && field === 'dial' ? Math.min(battleSupportBudget(g, p), (combatArmy(g, p.id, b.territory).normal + combatArmy(g, p.id, b.territory).elite)) : 0; for (let support = 0; support <= maxSupport; support++) { try { const plan = validatePlan(g, p, { leader, weapon, defense, support, dial: field === 'dial' ? value : 0, }); if (inspectedPlanMatches(p, field, value, plan[field])) return true; } catch (error) { if (!(error instanceof RuleError)) throw error; } } } return !!findReachableBattlePlan(g, p, { prescience: { field, value } }); } function lateDefenseIntegrity(g: Game) { const b = g.battle; if (!b?.lateDefense) return; requireRule( g.phase === 6 && b.revealed && !!b.event && !Array.isArray(b.lateDefense) && typeof b.lateDefense === 'object', 'The saved late-defense battle context is invalid.', ); for (const [id, cardId] of Object.entries(b.lateDefense)) { requireRule( [b.attacker, b.defender].includes(id) && typeof cardId === 'string' && !!b.plans[id], 'The saved late defense has an invalid combatant.', ); const owner = getPlayer(g, id), card = owner.hand.find((c) => c.id === cardId); requireRule( card && isPortableSnooper(card) && owner.hand.filter((c) => c.id === cardId).length === 1, 'The played Portable Snooper is missing from its reserved hand.', ); requireRule( !g.players.some( (p) => p.id !== id && p.hand.some((c) => c.id === cardId), ) && ![...g.deck, ...g.discard, ...(g.richeseCache ?? [])].some( (c) => c.id === cardId, ), 'The played Portable Snooper has conflicting physical custody.', ); const blocked = portableSnooperPlanBlock( b.plans[id], owner.hand, card, b.voice?.target === id ? b.voice : undefined, ); requireRule( !blocked, blocked ?? 'The saved late defense does not fit the revealed plan.', ); } } function validatePortableSnooper( g: Game, owner: Player, cardId: string, event: string, ) { const b = g.battle; requireRule( g.status === 'playing' && g.phase === 6 && b?.revealed && [b.attacker, b.defender].includes(owner.id), 'Late Portable Snooper is available after both battle plans are revealed.', ); requireRule( !!b.event && b.event === event, 'Use the current revealed battle event; legacy battles without one are not reopened.', ); requireRule( b.traitorCalls[owner.id] === undefined && !b.homeworldDefensePassed?.includes(owner.id), 'Your battle decisions are already submitted; the late-defense opportunity has closed.', ); requireRule( !g.truthtrance && !g.decision && !g.response && !g.phaseOpening && !g.pendingNullentropy, 'Finish the current interaction before adding a late defense.', ); requireRule( !b.lateDefense?.[owner.id], 'You have already added Portable Snooper to this battle.', ); const card = owner.hand.find((c) => c.id === cardId); requireRule( card && isPortableSnooper(card), 'Choose the canonical Portable Snooper in your hand.', ); const reserved = transferCardBlock(g, owner, card); requireRule(!reserved, reserved ?? 'The card is already committed.'); const blocked = portableSnooperPlanBlock( b.plans[owner.id], owner.hand, card, b.voice?.target === owner.id ? b.voice : undefined, ); requireRule( !blocked, blocked ?? 'Portable Snooper cannot be added to this plan.', ); const trial = structuredClone(g); (trial.battle!.lateDefense ??= {})[owner.id] = card.id; lateDefenseIntegrity(trial); return { b, card }; } function portableSnooperView(g: Game, owner: Player) { const card = owner.hand.find(isPortableSnooper); if (!card) return null; let blocked: string | null = null; try { validatePortableSnooper(g, owner, card.id, g.battle?.event ?? ''); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked = error.message; } return { card, event: g.battle?.event ?? null, blocked }; } function revealedWeaponChoices(g: Game) { const b = g.battle!; requireRule(b.revealed && !!b.plans[b.attacker] && !!b.plans[b.defender], 'Revealed weapon choices need both exact battle plans.'); const selected = (id: string) => { const p = getPlayer(g, id), plan = b.plans[id]; return { weapon: cardOf(p, plan.weapon), defense: cardOf(p, b.lateDefense?.[id] ?? plan.defense) }; }; const resolved = resolveBattleWeapons({ attacker: selected(b.attacker), defender: selected(b.defender) }); requireRule(!resolved.error, resolved.error ?? 'The revealed weapon choices are invalid.'); return resolved.choiceOrder.map((choice) => ({ ...choice, player: choice.side === 'attacker' ? b.attacker : b.defender, })); } function orderedRevealedWeaponChoices( g: Game, choices: ReturnType, ) { if (choices.some((choice) => choice.copiedFrom !== null)) return choices; return [ ...choices.filter((choice) => choice.kind === 'stoneBurner'), ...g.order.flatMap((id) => choices.filter((choice) => choice.kind === 'poisonTooth' && choice.player === id)), ]; } function revealedWeaponAnswered( b: Battle, choice: ReturnType[number], ) { return choice.kind === 'stoneBurner' ? b.stoneBurner?.[choice.player] !== undefined : b.poisonTooth?.[choice.player] !== undefined; } function battleSpecialWeaponIntegrity(g: Game) { const b = g.battle; if (!b) return; for (const id of [b.attacker, b.defender]) { const cardId = b.plans[id]?.weapon; if (cardId !== 'richese-stone-burner' && cardId !== 'richese-mirror-weapon') continue; const owner = getPlayer(g, id), card = cardOf(owner, cardId); const name = cardId === 'richese-stone-burner' ? 'Stone Burner' : 'Mirror Weapon'; if (cardId === 'richese-mirror-weapon') { const blocked = mirrorWeaponModeBlock(g); requireRule(!blocked, blocked ?? 'The committed Mirror Weapon mode is invalid.'); } requireRule(cardId === 'richese-stone-burner' ? isStoneBurner(card) : isMirrorWeapon(card), `The committed canonical ${name} weapon is missing from its reserved hand.`); requireRule(g.players.reduce((n, p) => n + p.hand.filter((c) => c.id === cardId).length, 0) === 1 && ![...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...(g.auction?.cards ?? []), ...(g.ixSetupCards ?? [])].some((c) => c.id === cardId), `The committed ${name} physical card is duplicated.`); } const choices = b.revealed ? revealedWeaponChoices(g) : []; if (b.stoneBurner !== undefined) { requireRule(g.phase === 6 && b.revealed && !!b.event && !!b.stoneBurner && typeof b.stoneBurner === 'object' && !Array.isArray(b.stoneBurner), 'The saved Stone Burner choice context is invalid.'); for (const [id, mode] of Object.entries(b.stoneBurner)) requireRule(['kill', 'ignore'].includes(mode) && choices.some((choice) => choice.player === id && choice.kind === 'stoneBurner' && choice.physicalId === b.plans[id]?.weapon), 'The saved Stone Burner choice has an invalid owner, mode, or physical weapon.'); } if (b.poisonTooth !== undefined) { requireRule(g.phase === 6 && b.revealed && !!b.poisonTooth && typeof b.poisonTooth === 'object' && !Array.isArray(b.poisonTooth), 'The saved Poison Tooth choice context is invalid.'); for (const [id, activated] of Object.entries(b.poisonTooth)) requireRule(typeof activated === 'boolean' && choices.some((choice) => choice.player === id && choice.kind === 'poisonTooth' && choice.physicalId === b.plans[id]?.weapon && (choice.copiedFrom === null || !!b.event)), 'The saved Poison Tooth choice has an invalid owner, answer, or physical weapon.'); } const ordered = orderedRevealedWeaponChoices(g, choices); let pendingSeen = false; for (const choice of ordered) { if (revealedWeaponAnswered(b, choice)) requireRule(!pendingSeen, 'A revealed weapon choice cannot precede its copied source.'); else pendingSeen = true; } if (g.decision?.kind === 'stoneBurner' || g.decision?.kind === 'poisonTooth') { const decision = g.decision; const choice = ordered.find((candidate) => !revealedWeaponAnswered(b, candidate)); requireRule(!!choice && choice.player === decision.player && choice.kind === decision.kind && choice.physicalId === b.plans[decision.player]?.weapon && (decision.event === undefined || decision.event === b.event) && (decision.physicalId === undefined || decision.physicalId === choice.physicalId) && (decision.copiedFrom === undefined || decision.copiedFrom === choice.copiedFrom) && (choice.copiedFrom === null || (decision.event === b.event && decision.physicalId === choice.physicalId && decision.copiedFrom === choice.copiedFrom)), 'The saved revealed weapon choice lost its physical card or battle event.'); } } function diplomatDefenseOffer(g: Game): (DiplomatDefenseQuote & { player: string }) | null { const b = g.battle; if (!b?.revealed || !g.leaderSkills || !diplomatDefenseModeSupported(g) || b.lateDefense && Object.keys(b.lateDefense).length) return null; for (const player of [b.attacker, b.defender]) { const p = getPlayer(g, player), plan = b.plans[player]; const other = getPlayer(g, b.attacker === player ? b.defender : b.attacker); const opposingPlan = b.plans[other.id]; if (!plan || !opposingPlan) continue; const quote = quoteDiplomatDefense({ assignments: g.leaderSkills.assignments.filter(a => a.owner === player && a.skill === 'diplomat' && p.leaders.some(l => l.id === a.leader && !l.dead && !l.capturedBy && !l.gholaBy)) .map(a => ({ skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[player], captured: false })), selectedLeader: plan.leader, weapon: cardOf(p, plan.weapon), defense: cardOf(p, plan.defense), opposingDefense: cardOf(other, opposingPlan.defense), }); if (quote) return { ...quote, player }; } return null; } function diplomatDefenseFrame(g: Game): string { const b = g.battle!; return JSON.stringify({ event: b.event, turn: g.turn, territory: b.territory, attacker: b.attacker, defender: b.defender, plans: b.plans, hidden: b.leaderSkillHidden, cards: [b.attacker, b.defender].map(id => { const p = getPlayer(g, id), plan = b.plans[id]; return [plan.weapon, plan.defense, plan.leader].map(selected => cardOf(p, selected) ?? null); }) }); } function diplomatDefenseSignature(receipt: DiplomatDefenseReceipt): string { return JSON.stringify({ ...receipt, signature: undefined }); } function diplomatDefenseIntegrity(g: Game) { const b = g.battle; const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'diplomatDefense'); if (b?.diplomatDefenseVersion === undefined && b?.diplomatDefense === undefined && b?.diplomatDefenseEvent === undefined && !decisions.length) return; requireRule(b && b.diplomatDefenseVersion === 1, 'The saved Diplomat choice lost its original battle version.'); const receipt = b.diplomatDefense; const offer = diplomatDefenseOffer(g); if (!receipt) { requireRule(receipt === undefined && b.diplomatDefenseEvent === undefined && !decisions.length && !offer, 'The revealed Diplomat defense lost its original choice.'); return; } requireRule(g.status === 'playing' && g.phase === 6 && b.revealed && offer && receipt.battle === b.event && receipt.turn === g.turn && receipt.territory === b.territory && typeof receipt.event === 'string' && receipt.event.length > 0 && receipt.event === b.diplomatDefenseEvent && receipt.frame === diplomatDefenseFrame(g) && receipt.signature === diplomatDefenseSignature(receipt) && JSON.stringify({leader: receipt.leader, cards: receipt.cards, source: receipt.source, kind: receipt.kind, player: receipt.player}) === JSON.stringify(offer), 'The saved Diplomat defense changed its revealed plans, trainer, source or physical card choices.'); requireRule(['offered', 'copied', 'declined'].includes(receipt.stage) && (receipt.stage === 'copied' ? typeof receipt.card === 'string' && receipt.cards.includes(receipt.card) : receipt.card === null), 'The saved Diplomat defense changed its optional choice or selected Worthless card.'); const physical = physicalTreacheryCards(g); for (const card of [...receipt.cards, receipt.source]) requireRule(physical.filter(c => c.id === card).length === 1, 'The committed Diplomat cards must retain unique physical custody.'); requireRule(decisions.length === (receipt.stage === 'offered' ? 1 : 0), 'The saved Diplomat defense lost or reopened its unique public decision.'); if (receipt.stage === 'offered') requireRule(Object.keys(b.traitorCalls).length === 0, 'Diplomat defense must be chosen before any traitor decisions.'); for (const decision of decisions) requireRule(decision.player === receipt.player && decision.event === receipt.event && decision.source === receipt.source && JSON.stringify(decision.cards) === JSON.stringify(receipt.cards), 'The Diplomat decision no longer matches its original public choices.'); } function offerDiplomatDefense(g: Game): boolean { const b = g.battle!; if (b.diplomatDefenseVersion !== 1 || b.diplomatDefense) return false; const offer = diplomatDefenseOffer(g); if (!offer) return false; requireRule(b.event && !Object.keys(b.traitorCalls).length, 'Diplomat defense needs the current revealed battle before traitor decisions.'); const event = crypto.randomUUID(); const receipt: DiplomatDefenseReceipt = { ...offer, event, battle: b.event, turn: g.turn, territory: b.territory, frame: diplomatDefenseFrame(g), stage: 'offered', card: null, signature: '' }; receipt.signature = diplomatDefenseSignature(receipt); b.diplomatDefense = receipt; b.diplomatDefenseEvent = event; g.decision = { kind: 'diplomatDefense', player: offer.player, event, cards: [...offer.cards], source: offer.source }; log(g, `${getPlayer(g, offer.player).name} may use Diplomat to make one committed Worthless card copy the opposing ${offer.kind === 'shield' ? 'Shield' : 'Snooper'} before traitor decisions.`, { faction: getPlayer(g, offer.player).faction, name: 'Diplomat defense' }); return true; } function harassAllocationOffer(g: Game) { const b = g.battle; if (!b?.revealed || b.harassAllocationVersion !== 1) return null; for (const id of [b.attacker, b.defender]) { const p = getPlayer(g, id), plan = b.plans[id]; if (![plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)) continue; const context = harassWithdrawContext(g, p); try { if (harassWithdrawNeedsAllocation(context, plan.dial, plan.support)) return { player: id, context, dial: plan.dial, support: plan.support }; } catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; } } return null; } function harassAllocationFrame(g: Game): string { const b = g.battle!; return JSON.stringify({ battle: b.event, turn: g.turn, territory: b.territory, attacker: b.attacker, defender: b.defender, plans: b.plans, offer: harassAllocationOffer(g) }); } function harassAllocationSignature(receipt: HarassAllocationReceipt): string { return JSON.stringify({ ...receipt, signature: undefined }); } function harassAllocationIntegrity(g: Game) { const b = g.battle, decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'harassWithdraw'); if (b?.harassAllocationVersion === undefined && b?.harassAllocation === undefined && b?.harassAllocationEvent === undefined && !decisions.length) return; requireRule(b && b.harassAllocationVersion === 1, 'The saved withdrawal choice lost its original battle version.'); const receipt = b.harassAllocation, offer = harassAllocationOffer(g); if (!receipt) { requireRule(receipt === undefined && b.harassAllocationEvent === undefined && !decisions.length && !offer, 'The revealed Harass & Withdraw plan lost its physical allocation choice.'); return; } requireRule(g.status === 'playing' && g.phase === 6 && b.revealed && offer && receipt.player === offer.player && receipt.battle === b.event && receipt.turn === g.turn && typeof receipt.event === 'string' && receipt.event.length > 0 && receipt.event === b.harassAllocationEvent && receipt.frame === harassAllocationFrame(g) && receipt.signature === harassAllocationSignature(receipt) && ['offered', 'selected'].includes(receipt.stage), 'The saved withdrawal choice changed its revealed plan, force locations or event.'); requireRule(decisions.length === (receipt.stage === 'offered' ? 1 : 0), 'The saved withdrawal choice lost or reopened its unique decision.'); if (receipt.stage === 'offered') requireRule(receipt.selection === null && !Object.keys(b.traitorCalls).length, 'Choose undialed forces before any traitor declarations.'); else { requireRule(receipt.selection !== null, 'The saved withdrawal choice lost its selected counters.'); try { quoteHarassWithdraw(offer.context, offer.dial, offer.support, receipt.selection); } catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; } } for (const decision of decisions) requireRule(decision.player === receipt.player && decision.event === receipt.event && Object.keys(decision).every(key => ['kind', 'player', 'event'].includes(key)), 'The withdrawal decision no longer matches its original owner and event.'); } function offerHarassAllocation(g: Game): boolean { const b = g.battle!; if (b.harassAllocation || b.harassAllocationVersion !== 1) return false; const offer = harassAllocationOffer(g); if (!offer) return false; requireRule(b.event && !Object.keys(b.traitorCalls).length, 'Choose withdrawal counters immediately after reveal.'); const event = crypto.randomUUID(); const receipt: HarassAllocationReceipt = { player: offer.player, event, battle: b.event, turn: g.turn, frame: harassAllocationFrame(g), stage: 'offered', selection: null, signature: '' }; receipt.signature = harassAllocationSignature(receipt); b.harassAllocation = receipt; b.harassAllocationEvent = event; g.decision = { kind: 'harassWithdraw', player: offer.player, event }; log(g, `${getPlayer(g, offer.player).name} must identify all undialed forces returned by Harass & Withdraw before traitor declarations.`); return true; } function nextRevealedDecision(g: Game) { const b = g.battle!; if (offerHarassAllocation(g)) return; if (offerDiplomatDefense(g)) return; const ordered = orderedRevealedWeaponChoices(g, revealedWeaponChoices(g)); const choice = ordered.find((candidate) => !revealedWeaponAnswered(b, candidate)); if (choice) { if (choice.kind === 'stoneBurner' || choice.copiedFrom) requireRule(!!b.event, 'The revealed weapon battle event is missing.'); g.decision = choice.kind === 'stoneBurner' ? { kind: 'stoneBurner', player: choice.player, event: b.event!, physicalId: choice.physicalId, copiedFrom: choice.copiedFrom } : { kind: 'poisonTooth', player: choice.player, event: b.event, physicalId: choice.physicalId, copiedFrom: choice.copiedFrom }; } } /** Homeworld invaders have no traitor vote to hold their late-defense window. * Offer only a real held legal defense; empty windows resolve automatically. */ function homeworldRevealPending(g: Game): boolean { const b = g.battle; if (g.status !== 'playing' || g.phase !== 6 || !b?.revealed || !b.territory.startsWith('homeworld:') || g.decision || g.response || g.truthtrance || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingRicheseGift || g.pendingExchange || g.pendingKarama || g.summonedWorm) return false; return traitorVoters(g, b).every((id) => b.traitorCalls[id] !== undefined); } function advanceHomeworldReveal(g: Game): boolean { if (!homeworldRevealPending(g)) return false; const b = g.battle!; const voters = traitorVoters(g, b); const owner = g.order.find((id) => [b.attacker, b.defender].includes(id) && !voters.includes(id) && !b.homeworldDefensePassed?.includes(id) && (() => { const offer = portableSnooperView(g, getPlayer(g, id)); return offer && !offer.blocked; })()); if (owner) { requireRule(b.event, 'The Homeworld defense opportunity needs its battle event.'); g.decision = {kind: 'homeworldDefense', player: owner, event: b.event}; } else advanceBattleOutcome(g); return true; } function traitorVoters(g: Game, b: Battle) { const home = homeworldBattleLocation(g, b.territory); if (home) return [b.attacker, b.defender].filter((id) => id === home.native); const voters = [b.attacker, b.defender]; const harkonnen = byFaction(g, 'harkonnen'); if ( harkonnen?.ally && voters.includes(harkonnen.ally) && !voters.includes(harkonnen.id) ) voters.push(harkonnen.id); return voters; } function traitorBeneficiary(g: Game, b: Battle, id: string) { return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally; } function pendingSmugglerBattle(g: Game): SmugglerBattleReceipt | null { const b = g.battle!; const frame = JSON.stringify({ plans: b.plans, hidden: b.leaderSkillHidden, skills: [b.attacker, b.defender].map(id => battleLeaderSkills(g, getPlayer(g, id))) }); const receipts = [b.attacker, b.defender].flatMap(id => { const p = getPlayer(g, id), plan = b.plans[id]; const opponent = getPlayer(g, id === b.attacker ? b.defender : b.attacker); const leader = controlledLeaders(g, p).find(l => l.id === plan.leader && !l.dead); const opposingLeader = controlledLeaders(g, opponent).find(l => l.id === b.plans[opponent.id].leader && !l.dead); const receipt = nexusRule(() => createSmugglerBattle({ event: b.event!, turn: g.turn, territory: b.territory, player: id, frame, spice: g.spice, supported: smugglerBattleModeSupported(g), plan: { assignments: battleLeaderSkills(g, p), leader: leader ? { id: leader.id, strength: battleLeaderStrength(leader, opposingLeader) } : undefined, weapon: cardOf(p, plan.weapon), defense: cardOf(p, plan.defense), kwisatz: plan.kwisatz } })); return receipt ? [receipt] : []; }); requireRule(receipts.length <= 1, 'Smuggler needs its unique physical skill card.'); return receipts[0] ?? null; } function smugglerBattleIntegrity(g: Game) { const b = g.battle; if (b?.smugglerCollectionVersion !== undefined || b?.smugglerCollection !== undefined) { requireRule(b && b.smugglerCollectionVersion === 1, 'The saved Smuggler collection lost its battle version.'); if (b.revealed) requireRule(JSON.stringify(b.smugglerCollection) === JSON.stringify(pendingSmugglerBattle(g)), 'The saved Smuggler collection changed its original plans, leader or reveal-time spice.'); else requireRule(b.smugglerCollection === undefined, 'Smuggler collection cannot precede public Battle Plans.'); } const context = g.lastBattleContext, receipt = context?.smugglerCollection; if (receipt) requireRule(receipt.event === context!.event && receipt.turn === context!.turn && receipt.territory === context!.territory && context!.combatants.includes(receipt.player) && ['collected', 'void'].includes(receipt.stage) && receipt.signature === smugglerBattleSignature(receipt), 'The completed Smuggler collection changed its original battle receipt.'); } /** Prepare current server-only inputs once; the pure module cannot inspect Game or mutate it. */ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) { const b = g.battle!; const combatant = (id: string, opponentId: string): ResolutionCombatant => { const p = getPlayer(g, id), opponent = getPlayer(g, opponentId); const plan = b.plans[id], aid = aidFor(g, p); return { id: p.id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan, leader: controlledLeaders(g, p).find((l) => l.id === plan.leader), leaderSkills: battleLeaderSkills(g, p), occupiedStrongholds: leaderSkillStrongholdCount(g, p), forces: combatForces(g, p, b.territory, opponent), ...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isReinforcements) ? { reinforcementsReserves: { normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0, } } : {}), ...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isHarassWithdraw) ? { harassWithdraw: harassWithdrawContext(g, p), ...(b.harassAllocation?.player === p.id && b.harassAllocation.stage === 'selected' ? { harassSelection: b.harassAllocation.selection! } : {}) } : {}), stronghold: strongholdEffect(g, p.id), lateDefense: b.lateDefense?.[p.id], ...(b.diplomatDefense?.stage === 'copied' && b.diplomatDefense.player === p.id ? { diplomatDefense: { leader: b.diplomatDefense.leader, card: b.diplomatDefense.card!, source: b.diplomatDefense.source, kind: b.diplomatDefense.kind } } : {}), poisonTooth: b.poisonTooth?.[p.id], stoneMode: b.stoneBurner?.[p.id], ...(aid ? { aid: { donor: p.ally!, amount: aid.amount } } : {}), }; }; try { const quote = quoteBattleResolution({ advanced: g.advanced, aggressor: battleAggressor(g), typedCasualties: !!g.homeworlds, ...(homeworldBattleLocation(g, b.territory) ? { homeworld: currentHomeworldBattleRules(g, b.territory)! } : {}), turn: g.turn, territory: b.territory, attacker: combatant(b.attacker, b.defender), defender: combatant(b.defender, b.attacker), voters: traitorVoters(g, b).map((id) => ({ id, beneficiary: traitorBeneficiary(g, b, id)!, called: id === canceledVoter ? false : b.traitorCalls[id], traitors: b.traitorDeclarations?.[id] ? [b.traitorDeclarations[id].identity] : getPlayer(g, id).traitors, })), participants: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, noFieldAtTerritory: p.noField?.deployed?.location.territory === b.territory, })), physicalCards: physicalTreacheryCards(g), pendingAuditorPresent: !!g.pendingAuditor, pendingRetentionPresent: !!g.moritaniRetention, nexusMoritani: !!g.nexusCards?.cards && !byFaction(g, 'moritani'), }); for (const withdrawal of quote.harassWithdraw ?? []) validateReserveReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned); if (quote.sukGraduate) requireRule( !g.advanced || getPlayer(g, quote.winner!).faction !== 'atreides', 'Suk Graduate rescue for Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.'); if (quote.sukGraduate) requireRule( ordinaryLeaderSkillModeSupported(g) && (basicExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')), 'Suk Graduate rescue with expansion factions or other optional modules is still being implemented.'); if (quote.rihani || quote.sandmaster) requireRule( ordinaryLeaderSkillModeSupported(g) && (basicExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')), 'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.'); const smuggler = b.smugglerCollection ? nexusRule(() => settleSmugglerBattle(b.smugglerCollection!, !(b.smugglerCollection!.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender), g.spice)) : null; if (smuggler) requireRule(Number.isSafeInteger(getPlayer(g, smuggler.receipt.player).spice + smuggler.amount), 'Smuggler collection would overflow the spice balance.'); if (quote.sandmaster) nexusRule(() => sandmasterVictorySpice(b.territory, smuggler?.spice ?? g.spice)); if (quote.rihani) { const winner = getPlayer(g, quote.winner!); nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), { event: b.event!, turn: g.turn, owner: winner.id, skill: quote.rihani! }, [...new Set([...(winner.revealedTraitors ?? []), ...quote.revelations.filter((r) => r.player === winner.id).map((r) => r.identity)])], () => 0)); } for (const id of quote.destroyedArmies) if (b.territory.startsWith('homeworld:')) quoteHomeworldLoss(g, id, b.territory, combatArmy(g, id, b.territory)); else validateBattleForceLoss(getPlayer(g, id), b.territory, Infinity); if (quote.winner && quote.result === 'normal' && b.territory.startsWith('homeworld:')) for (const losses of quote.casualties!.options) quoteHomeworldLoss(g, quote.winner, b.territory, { normal: losses.normal, elite: losses.elite }); else if (quote.winner && quote.result === 'normal') validateBattleForceLoss( getPlayer(g, quote.winner), b.territory, quote.basicWinnerLosses ?? Math.max(...quote.casualties!.options.map((c) => c.normal + c.elite)), ); if (quote.homeworldExplosion) for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses); return { ...quote, smuggler }; } catch (error) { if ( error instanceof BattleResolutionQuoteError || error instanceof ForceLossPreflightError ) throw new RuleError(error.message); throw error; } } function diplomatRetreatBlocked(g: Game, key: string) { const sector = key === MOBILE_LOCATION ? splitLocation(g.mobileStronghold?.location ?? '').sector : splitLocation(key).sector; return sector > 0 && sector === g.storm; } function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) { const b = g.battle!; if (b.diplomatRetreatVersion !== 1 || quote.result !== 'normal' || !quote.winner || b.territory.startsWith('homeworld:')) return null; const player = quote.winner === b.attacker ? b.defender : b.attacker; const p = getPlayer(g, player); const plan = b.plans[player]; const leader = controlledLeaders(g, p).find(candidate => candidate.id === plan.leader && !candidate.dead); if (!leader || (player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender) || !battleLeaderSkills(g, p).some(skill => skill.skill === 'diplomat' && skill.leader === leader.id && (!skill.faceUp || skill.captured)) || p.noField?.deployed?.location.territory === b.territory) return null; const opponent = getPlayer(g, quote.winner); const forces = combatForces(g, p, b.territory, opponent); const strength = battleLeaderStrength(leader, controlledLeaders(g, opponent).find(candidate => candidate.id === b.plans[opponent.id].leader)); const origins = Object.entries(p.forces).filter(([key, count]) => count > 0 && splitLocation(key).territory === b.territory); const destinations = TERRITORIES.filter(candidate => candidate.type !== 'stronghold' && candidate.id !== b.territory && !g.players.some(side => Object.entries(side.forces).some(([key, count]) => count > 0 && splitLocation(key).territory === candidate.id))) .flatMap(candidate => candidate.sectors.map(sector => location(candidate.id, sector))) .sort() .map(key => { const available = origins.reduce((sum, [origin, count]) => { if (gameDistance(g, origin, key, candidate => diplomatRetreatBlocked(g, candidate)) !== 1) return sum; const elite = p.elites?.forces[origin] ?? 0; return { normal: sum.normal + count - elite, elite: sum.elite + elite }; }, { normal: 0, elite: 0 }); return { location: key, choices: diplomatRetreatChoices(forces, plan.dial, plan.support, strength, available) }; }) .filter(option => option.choices.length); return destinations.length ? { player, destinations } : null; } function diplomatRetreatFrame(g: Game) { const b = g.battle!; return JSON.stringify({ battle: b.event, turn: g.turn, territory: b.territory, plans: b.plans, hidden: b.leaderSkillHidden, traitors: b.traitorCalls, forces: g.players.map(p => [p.id, p.forces, p.elites?.forces]), skills: g.leaderSkills?.assignments, storm: g.storm }); } function diplomatRetreatIntegrity(g: Game) { const b = g.battle, receipt = b?.diplomatRetreat; const decision = g.decision; if (!receipt) { requireRule(decision?.kind !== 'diplomatRetreat', 'The Diplomat retreat lost its battle.'); return; } requireRule(b?.diplomatRetreatVersion === 1 && g.status === 'playing' && g.phase === 6 && b.revealed && b.event === receipt.battle && g.turn === receipt.turn && receipt.event && receipt.frame === diplomatRetreatFrame(g) && JSON.stringify(diplomatRetreatOffer(g, currentBattleResolutionQuote(g))) === JSON.stringify({ player: receipt.player, destinations: receipt.destinations }) && decision?.kind === 'diplomatRetreat' && decision.event === receipt.event && decision.player === receipt.player && JSON.stringify(decision.destinations) === JSON.stringify(receipt.destinations), 'The saved Diplomat retreat changed its battle, forces, outcome or legal destinations.'); } function advanceBattleOutcome(g: Game) { const b = g.battle!; if (b.diplomatRetreatVersion !== 1 || ![b.attacker, b.defender].some(id => battleLeaderSkills(g, getPlayer(g, id)).some(skill => skill.skill === 'diplomat' && b.plans[id].leader === skill.leader && (!skill.faceUp || skill.captured)))) { resolveBattle(g); return; } const offer = diplomatRetreatOffer(g, currentBattleResolutionQuote(g)); if (!offer) { resolveBattle(g); return; } requireRule(b.event && !b.diplomatRetreat, 'The Diplomat retreat needs one current battle.'); const event = crypto.randomUUID(); b.diplomatRetreat = { ...offer, event, battle: b.event, turn: g.turn, frame: diplomatRetreatFrame(g) }; g.decision = { kind: 'diplomatRetreat', event, ...offer }; log(g, `${getPlayer(g, offer.player).name} may retreat surviving undialed forces with Diplomat before battle losses.`, { faction: getPlayer(g, offer.player).faction, name: 'Diplomat retreat' }); } function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { const b = g.battle!; requireRule(!!b.diplomatRetreat === !!retreat, 'Resolve the pending Diplomat retreat before battle losses.'); const quote = currentBattleResolutionQuote(g); const a = getPlayer(g, b.attacker), d = getPlayer(g, b.defender), ap = b.plans[a.id], dp = b.plans[d.id]; const al = controlledLeaders(g, a).find((l) => l.id === ap.leader), dl = controlledLeaders(g, d).find((l) => l.id === dp.leader); const aw = cardOf(a, ap.weapon); const stoneResult = quote.stone; const ac = quote.attackerTraitor, dc = quote.defenderTraitor; const losingPlayer = quote.winner ? quote.winner === a.id ? d : a : null; const losingForces = losingPlayer ? combatForces(g, losingPlayer, b.territory, losingPlayer === a ? d : a) : null; const losingPlan = losingPlayer ? b.plans[losingPlayer.id] : null; const jacurutuIncome = quoteJacurutuBattleIncome({ territory: b.territory, jacurutuRevealed: g.discoveryEnabled === true && !!g.discoveries?.tokens.some( (token) => token.face === 'jacurutu-sietch' && token.status === 'placed' && token.revealedTurn !== null, ), winner: quote.winner, opponent: losingPlayer?.id ?? null, opponentForces: losingForces, opponentDial: losingPlan?.dial ?? null, opponentSupport: losingPlan?.support ?? null, sentToTanks: losingForces ? { normal: losingForces.normal, elite: losingForces.elite } : null, }); for (const revelation of quote.revelations) { const holder = getPlayer(g, revelation.player), identity = revelation.identity; holder.revealedTraitors = [ ...new Set([...(holder.revealedTraitors ?? []), identity]), ]; const name = identity === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : (g.players.flatMap((p) => p.leaders).find((l) => l.id === identity) ?.name ?? identity); log(g, `${holder.name} revealed ${name} as a traitor.`); if (holder.id === g.moritaniAssassinate?.owner) log(g, `${holder.name} called a traitor normally. Further Assassinate Leaders use awaits clarification of the printed loss-of-advantage rule.`, {faction:'moritani',name:'Assassinate Leaders boundary'}); } const dead = (l: Leader | undefined) => { if (l) { l.dead = true; l.deaths++; delete l.usedAt; } }; const winner = quote.winner ? getPlayer(g, quote.winner) : undefined; const casualtyCommitment = quote.casualties ?? undefined; const sardaukar = currentNexusSardaukar(g); if (sardaukar?.stage === 'active' && casualtyCommitment && winner?.id === sardaukar.receipt.owner) { sardaukar.casualties = {...structuredClone(casualtyCommitment),outcome:'pending'}; sardaukar.signature = nexusSardaukarSignature(sardaukar); } for (const payment of quote.payments) { const player = getPlayer(g, payment.player); player.spice -= payment.ownPayment; if (payment.allyPayment) aidFor(g, player)!.amount -= payment.allyPayment; if (payment.bankSupport) log( g, `${player.name}'s Stronghold Card paid ${payment.bankSupport} spice from the bank toward force support.`, { faction: player.faction, name: 'Stronghold support' }, ); } for (const payment of quote.spiceBankerPayments ?? []) { const player = getPlayer(g, payment.player); player.spice -= payment.amount; log(g, payment.waivedByTraitor ? `${player.name}'s successful traitor call waived the committed Spice Banker payment along with other Battle Plan spice.` : `${player.name} paid ${payment.amount} committed Spice Banker spice to the bank, separate from force support. The payment is spent win or lose; only a surviving skilled leader receives its strength bonus.`, { faction: player.faction, name: 'Spice Banker payment' }); } for (const cost of quote.reinforcements ?? []) { const player = getPlayer(g, cost.player); const current = currentReinforcementsCost(g, player); requireRule(cost.normal === current.normal && cost.elite === current.elite, 'Reinforcements lost its original physical reserve cost.'); player.reserves -= cost.normal + cost.elite; player.tanks += cost.normal + cost.elite; player.battleLosses += cost.normal + cost.elite; if (cost.elite) { requireRule(player.elites, 'Reinforcements lost its elite reserve custody.'); player.elites.reserves -= cost.elite; player.elites.tanks += cost.elite; } log(g, `${player.name} used Reinforcements: three reserve forces (${cost.elite} elite) went to the Tanks, and the dialed battle strength gained 2 without adding on-board forces or spice support.`, { faction: player.faction, name: 'Reinforcements' }); } if (quote.choamIncome) g.pendingChoamBattleIncome = { ...quote.choamIncome }; for (const [player, plan, canceled] of [[a, ap, dc], [d, dp, ac]] as const) if (canceled && [cardOf(player, plan.weapon), cardOf(player, plan.defense)].some(isHarassWithdraw)) log(g, `${player.name}'s Harass & Withdraw was canceled by an opposing traitor. No forces returned through this card.`); for (const withdrawal of quote.harassWithdraw ?? []) { const player = getPlayer(g, withdrawal.player); for (const [key, group] of Object.entries(withdrawal.locations)) { player.forces[key] -= group.normal + group.elite; if (!player.forces[key]) delete player.forces[key]; if (group.elite && player.elites) { player.elites.forces[key] -= group.elite; if (!player.elites.forces[key]) delete player.elites.forces[key]; } } player.reserves += withdrawal.returned.normal + withdrawal.returned.elite; if (player.elites) player.elites.reserves += withdrawal.returned.elite; log(g, `${player.name} used Harass & Withdraw to return ${withdrawal.returned.normal + withdrawal.returned.elite} undialed physical forces (${withdrawal.returned.elite} elite) to reserves before battle losses. Their leader still resolves normally.`); } if (quote.harassWithdraw?.length) observeOccupation(g); const explosion = quote.explosion; if (ac && dc) { dead(al); dead(dl); killTerritory(g, a, b.territory, Infinity, true); killTerritory(g, d, b.territory, Infinity, true); observeOccupation(g); log(g, 'Both leaders were traitors. Both armies were destroyed.'); } else if (ac || dc) { const loser = ac ? d : a; const l = ac ? dl : al; dead(l); winner!.spice += quote.bounty!.amount; killTerritory(g, loser, b.territory, Infinity, true); observeOccupation(g); log( g, `${faction(winner!.faction).name} revealed a traitor and won without losses.`, ); } else if (explosion) { returnAmbassadorsIn( g, b.territory, 'a Lasgun–shield explosion destroyed it', ); for (const player of quote.destroyedArmies) killTerritory(g, getPlayer(g, player), b.territory, Infinity, true); observeOccupation(g); dead(al); dead(dl); for (const [p, plan] of [ [a, ap], [d, dp], ] as [Player, Plan][]) if (plan.kwisatz) { p.kwisatz ??= { dead: false }; p.kwisatz.dead = true; p.kwisatz.revivalCycle ??= 1; } for (const k of Object.keys(g.spice)) if (splitLocation(k).territory === b.territory) delete g.spice[k]; log( g, quote.homeworldExplosion ? `Lasgun and shield exploded. Invading armies were destroyed; the native faction loses ${quote.homeworldExplosion.amount} physical forces, limited by its Homeworld’s printed battle strength.` : 'Lasgun and shield exploded. All forces and spice in the territory were destroyed.', ); } else { const ak = quote.leaderDeaths.attacker, dk = quote.leaderDeaths.defender; if (ak) dead(al); if (dk) dead(dl); const av = quote.scores!.attacker, dv = quote.scores!.defender; if (!stoneResult && quote.leaderSkillPenalties) for (const [target, amount] of [[a, quote.leaderSkillPenalties.attacker], [d, quote.leaderSkillPenalties.defender]] as const) if (amount) log(g, `${target.name}'s battle total fell by ${amount} because the opposing Bureaucrat counted ${amount} occupied strongholds. Advisors do not count; the leader's printed strength and bounty are unchanged.`, { faction: (target === a ? d : a).faction, name: 'Bureaucrat' }); if (!stoneResult && !quote.effects?.stunned) for (const [player, receipt, died] of [[a, quote.leaderSkillBonuses.attacker, ak], [d, quote.leaderSkillBonuses.defender, dk]] as const) for (const bonus of died ? [] : receipt.applied) log(g, `${player.name} gained ${bonus.amount} battle strength from ${leaderSkillCard(bonus.skill).name}${bonus.mode === 'normal' ? ' while their skilled leader remained face up' : ' by using their surviving skilled leader'}.`, {faction:player.faction,name:'Leader Skill'}); const loser = winner === a ? d : a; if (quote.bounty) winner!.spice += quote.bounty.amount; if (retreat?.destination) { let normal = retreat.normal, elite = retreat.elite; for (const key of Object.keys(loser.forces).sort()) { if (splitLocation(key).territory !== b.territory || (!normal && !elite) || gameDistance(g, key, retreat.destination, candidate => diplomatRetreatBlocked(g, candidate)) !== 1) continue; const availableElite = loser.elites?.forces[key] ?? 0; const takenNormal = Math.min(normal, loser.forces[key] - availableElite); const takenElite = Math.min(elite, availableElite); loser.forces[key] -= takenNormal + takenElite; if (!loser.forces[key]) delete loser.forces[key]; if (takenElite && loser.elites) { loser.elites.forces[key] -= takenElite; if (!loser.elites.forces[key]) delete loser.elites.forces[key]; } normal -= takenNormal; elite -= takenElite; } requireRule(!normal && !elite, 'The Diplomat retreat lost its undialed physical counters.'); loser.forces[retreat.destination] = (loser.forces[retreat.destination] ?? 0) + retreat.normal + retreat.elite; if (retreat.elite && loser.elites) loser.elites.forces[retreat.destination] = (loser.elites.forces[retreat.destination] ?? 0) + retreat.elite; log(g, `${loser.name} retreated ${retreat.normal} normal and ${retreat.elite} elite undialed forces with Diplomat to ${territory(splitLocation(retreat.destination).territory).name}.`, { faction: loser.faction, name: 'Diplomat retreat' }); observeOccupation(g); } killTerritory(g, loser, b.territory, Infinity, true); observeOccupation(g); if (quote.basicWinnerLosses !== null) killTerritory(g, winner!, b.territory, quote.basicWinnerLosses, true); observeOccupation(g); log( g, stoneResult ? `${faction(winner!.faction).name} won in ${combatLocationName(g, b.territory)} by Stone Burner’s undialed physical tokens (${a.name} ${stoneResult.attacker.join(' or ')}, ${d.name} ${stoneResult.defender.join(' or ')}; ${strongholdEffect(g, battleTieWinner(g)) === 'habbanya_ridge_sietch' ? 'Habbanya Stronghold advantage wins ties' : 'aggressor wins ties'}). Leader strength and Kwisatz Haderach’s bonus do not affect this comparison; the winner loses its dialed forces normally.` : `${faction(winner!.faction).name} won in ${combatLocationName(g, b.territory)} (${av}–${dv}${av === dv ? (strongholdEffect(g, winner!.id) === 'habbanya_ridge_sietch' ? ', Habbanya Stronghold advantage wins ties' : ', aggressor wins ties') : ''}).`, stoneResult ? { faction: (isStoneBurner(aw) ? a : d).faction, name: 'Stone Burner' } : { faction: winner!.faction, name: 'Battle' }, ); } if (jacurutuIncome?.kind === 'income' && jacurutuIncome.amount > 0) { winner!.spice += jacurutuIncome.amount; log( g, `${winner!.name} gained ${jacurutuIncome.amount} spice from the bank at Jacurutu Sietch for ${jacurutuIncome.amount} opposing undialed force${jacurutuIncome.amount === 1 ? '' : 's'} sent to the Tanks.`, { faction: winner!.faction, name: 'Jacurutu Sietch income' }, ); } else if (jacurutuIncome?.kind === 'allocationRequired') { const amounts = [ ...new Set(jacurutuIncome.choices.map((choice) => choice.amount)), ].sort((left, right) => left - right); log( g, `Jacurutu Sietch income remains unpaid: ${losingPlayer!.name}'s revealed dial and support permit ${amounts.join(' or ')} undialed physical forces among the counters sent to the Tanks. The physical dial allocation needs a player choice.`, ); } for (const income of quote.strongholdIncome) { const player = getPlayer(g, income.player); player.spice += income.amount; log( g, `${player.name} collected ${income.amount} spice from the bank for their ${strongholdEffect(g, player.id) === 'sietch_tabr' ? 'Sietch Tabr victory' : 'Worthless battle cards at Tuek’s Sietch'}.`, { faction: player.faction, name: 'Stronghold income' }, ); } const tleilaxu = byFaction(g, 'tleilaxu'); if (winner && tleilaxu && winner.id !== tleilaxu.id && (!b.territory.startsWith('homeworld:') || homeworldBattleLocation(g, b.territory)!.native === tleilaxu.id)) { const winningPlan = winner.id === a.id ? ap : dp; g.pendingFaceDance = { player: tleilaxu.id, winner: winner.id, leader: winningPlan.leader, identity: cardOf(winner, winningPlan.leader)?.kind === 'hero' ? CHEAP_HERO_TRAITOR : winningPlan.leader, territory: b.territory, }; } if (quote.auditor) g.pendingAuditor = { ...quote.auditor, event: b.event ?? crypto.randomUUID(), }; if (quote.retention) g.moritaniRetention = structuredClone(quote.retention); const playedCardRoles = g.homeworlds?.custody && byFaction(g, 'ecaz') ? battleDiscardRoles(g, b) : undefined; const winningDiscards = quote.discarded.filter((entry) => entry.player === winner?.id).map((entry) => entry.card); const discarded: { card: Card; discardedBy: string; publicFace: boolean }[] = []; for (const [p, plan, l] of [ [a, ap, al], [d, dp, dl], ] as [Player, Plan, Leader | undefined][]) { if (l && !l.dead) l.usedAt = b.territory; if (l?.capturedBy === p.id) { delete l.capturedBy; delete l.concealed; } if (plan.kwisatz) { p.kwisatz ??= { dead: false }; p.kwisatz.usedAt = b.territory; } for (const entry of quote.discarded.filter( (entry) => entry.player === p.id && entry.player !== winner?.id, )) discarded.push({ card: discard(g, p, entry.card, playedCardRoles?.[p.id]?.[entry.card]), discardedBy: p.id, publicFace: true, }); } const harkonnen = byFaction(g, 'harkonnen'); if (harkonnen) returnCaptives(g, harkonnen); if (g.advanced && winner?.faction === 'harkonnen') { const loser = winner.id === a.id ? d : a; if (captureCandidates(g, loser.id, b.territory).length) g.pendingCapture = { player: winner.id, loser: loser.id, territory: b.territory, }; } if (winner) { const loser = winner.id === a.id ? d : a; const choices = ownedTech(g.techTokens, loser.id); if (choices.length) g.pendingTech = { player: winner.id, loser: loser.id, choices }; } if (g.dukeVidal && [ap.leader, dp.leader].includes(DUKE_VIDAL_ID)) g.dukeVidal = consumeDuke(g.dukeVidal); g.lastBattle = [a.id, d.id]; const battleEvent = b.event ?? g.pendingAuditor?.event ?? crypto.randomUUID(); const inspection: NexusChoamInspectionReceipt | null = winner && g.nexusCards?.cards && !byFaction(g, 'choam') ? { event: battleEvent, opponent: winner.id === a.id ? d.id : a.id, usedCards: [...(winner.id === a.id ? quote.played.defender : quote.played.attacker)], stage: 'pending', signature: '', } : null; if (inspection) inspection.signature = nexusChoamInspectionSignature(inspection); const nexusRetention: NexusMoritaniRetentionReceipt | null = quote.retention?.source === 'nexus' ? { event: battleEvent, player: quote.retention.player, played: [...quote.retention.played], eligible: [...quote.retention.eligible], stage: 'pending', signature: '', } : null; if (nexusRetention) { nexusRetention.signature = nexusMoritaniRetentionSignature(nexusRetention); g.moritaniRetention!.event = battleEvent; } g.lastBattleContext = { event: battleEvent, turn: g.turn, territory: b.territory, combatants: [...g.lastBattle], winner: winner?.id ?? null, result: quote.result, ...(playedCardRoles ? { cardRoles: playedCardRoles } : {}), ...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}), ...(inspection ? { nexusChoamInspection: inspection } : {}), ...(nexusRetention ? { nexusMoritaniRetention: nexusRetention } : {}), }; recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer); if (quote.smuggler) { const { receipt, amount } = quote.smuggler; const collector = getPlayer(g, receipt.player); if (amount && receipt.key) g.spice[receipt.key] = receipt.before - amount; collector.spice += amount; g.lastBattleContext.smugglerCollection = receipt; log(g, receipt.stage === 'void' ? `${collector.name}'s Smuggler did not survive. Its pending collection is void; no spice was taken from ${combatLocationName(g, b.territory)}.` : `${collector.name}'s surviving Smuggler collected ${amount} spice from ${combatLocationName(g, b.territory)}. The reveal-time pile held ${receipt.before}; the leader's unmodified strength was ${receipt.strength}. ${receipt.before - amount} spice remains before any after-victory effects.`, { faction: collector.faction, name: 'Smuggler collection' }); } if (winner && quote.sandmaster) { const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice)); if (placement) { g.spice[placement.key] = placement.after; g.lastBattleContext.sandmaster = { leader: quote.sandmaster, ...placement }; log(g, `${winner.name}'s surviving Sandmaster added 3 spice to the existing pile in ${combatLocationName(g, b.territory)}, sector ${splitLocation(placement.key).sector}. The territory now contains ${placement.after} spice; this is board spice, not income.`, { faction: winner.faction, name: 'Sandmaster' }); } } if (winner && quote.rihani) { g.lastBattleContext.rihani = { skill: quote.rihani, cards: [...quote.winnerCards], completed: false, signature: '' }; g.lastBattleContext.rihani.signature = rihaniObligationSignature(g.lastBattleContext); } if (winner && quote.sukGraduate && casualtyCommitment) { requireRule(!g.pendingSukRescue, 'Finish the preceding Suk Graduate rescue.'); g.pendingSukRescue = { event: g.lastBattleContext.event, turn: g.turn, player: winner.id, territory: b.territory, skill: quote.sukGraduate, commitment: structuredClone(casualtyCommitment), pool: sukForcePool(winner, b.territory), cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner), losses: null, signature: '', ...(winner.faction === 'ixians' ? { eliteOrigins: true as const } : {}), }; updateSukReceipt(g); } if (g.homeworlds?.custody && winner?.faction === 'atreides' && (quote.result === 'normal' || quote.result === 'traitor')) { requireRule(!g.homeworldVictoryReinforcement || g.homeworldVictoryReinforcement.stage === 'complete', 'Finish the previous Caladan reinforcement before resolving another battle.'); g.homeworldVictoryReinforcement = makeHomeworldVictoryReturn({ event: g.lastBattleContext.event, turn: g.turn, player: winner.id, territory: b.territory, result: quote.result, }); g.lastBattleContext.caladanReinforcement = { event: g.lastBattleContext.event, completed: false, stage: 'waiting', signature: homeworldVictoryObligationSignature(g.lastBattleContext, false, 'waiting'), }; } if (playedCardRoles) g.lastBattleContext.cardRolesSignature = battleCardRolesSignature(g.lastBattleContext); if (winner && winningDiscards.length) g.pendingWinnerDiscards = { event: g.lastBattleContext.event, turn: g.turn, territory: b.territory, player: winner.id, cards: winningDiscards, optional: [...quote.winnerCards], signature: '' }; if (g.pendingWinnerDiscards) g.pendingWinnerDiscards.signature = winnerDiscardSignature(g.pendingWinnerDiscards); if (g.pendingWinnerDiscards) { g.lastBattleContext.winnerDiscards = { cards: [...winningDiscards], completed: false, signature: '' }; g.lastBattleContext.winnerDiscards.signature = winnerDiscardObligationSignature(g.lastBattleContext); } if (b.territory.startsWith('homeworld:') && (casualtyCommitment || quote.homeworldExplosion)) { const owner = quote.homeworldExplosion?.player ?? winner!.id; g.homeworldBattleLoss = { event: g.lastBattleContext.event, territory: b.territory, player: owner, kind: quote.homeworldExplosion ? 'explosion' : 'winner', pool: combatArmy(g, owner, b.territory), options: (quote.homeworldExplosion?.options ?? casualtyCommitment!.options).map(({normal, elite}) => ({normal, elite})), ...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support } } : {}), }; } if (discarded.length) { g.lastBattleContext.mandatoryDiscard = { entries: discarded.map(entry => ({ card: entry.card.id, owner: entry.discardedBy })), signature: '', }; g.lastBattleContext.mandatoryDiscard.signature = battleMandatoryDiscardSignature(g.lastBattleContext); } g.battle = null; const cards = [...quote.winnerCards]; const continuation: Extract< NonNullable['continuation'], { kind: 'battleResolved' } > = { kind: 'battleResolved', event: g.lastBattleContext.event, result: quote.result, combatants: [...g.lastBattle], territory: b.territory, winner: winner?.id ?? null, cards, ...(casualtyCommitment ? { casualties: casualtyCommitment } : {}), }; if (g.lastBattleContext.moritaniAssassinate) g.lastBattleContext.moritaniAssassinate.continuation = JSON.stringify(continuation); if (discarded.length) stageTreacheryDiscard(g, 'battle:mandatory', discarded, continuation); else continueResolvedBattle(g, continuation); } function continueResolvedBattle( g: Game, continuation: Extract< NonNullable['continuation'], { kind: 'battleResolved' } >, ) { const assassination = g.moritaniAssassinate?.opportunities.find(r => r.stage === 'choice'); if (assassination) { requireRule(assassination.event === continuation.event && !g.moritaniAssassinateResume, 'The assassination opportunity must preserve its original battle cleanup.'); g.moritaniAssassinateResume = {event:assassination.event,continuation:structuredClone(continuation),signature:JSON.stringify(continuation)}; g.decision = {kind:'moritaniAssassinate',player:assassination.owner,event:assassination.event}; return; } const { cards, territory: to } = continuation; const winner = continuation.winner ? getPlayer(g, continuation.winner) : null; const losses = continuation.casualties?.options; const native = g.homeworldBattleLoss; if (native?.kind === 'explosion') { homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); if (native.options.length === 1) settleHomeworldExplosion(g, native.options[0], true); else g.decision = { kind: 'homeworldExplosion', event: native.event, player: native.player, territory: native.territory, options: native.options.map((p) => ({...p})), pool: {...native.pool} }; return; } if (winner && losses?.length === 1) { settleWinnerCasualties(g, winner, to, cards, losses[0], true); } else if (winner && losses) g.decision = { kind: 'battleLosses', player: winner.id, territory: to, options: losses, cards, }; else if (winner) finishWinner(g, winner, to, cards); else finishBattle(g); } function settleWinnerCasualties( g: Game, p: Player, to: string, cards: string[], choice: Casualties, automatic = false, ) { if (g.pendingSukRescue) { const pending = g.pendingSukRescue; requireRule(pending.player === p.id && pending.territory === to && JSON.stringify(cards) === JSON.stringify(pending.cards) && !pending.losses && pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(choice)), 'Choose the original Suk Graduate casualty allocation.'); pending.losses = { ...choice }; updateSukReceipt(g); const options = sukRescueOptions(pending.skill, pending.pool, choice, pending.eliteOrigins); if (options.length === 1) settleSukRescue(g, options[0], true); else g.decision = { kind: 'sukRescue', player: p.id, event: pending.event, territory: to, mode: pending.skill.mode, options }; return; } if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g); const sardaukar = g.nexusSardaukarHistory?.find(record => record.receipt.battle === g.lastBattleContext?.event && record.receipt.owner === p.id); if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' && sardaukar.casualties.options.some(option => JSON.stringify(option) === JSON.stringify(choice)), 'Choose an original Nexus Sardaukar casualty allocation.'); const losses = takeBattleLosses(g, p, to, choice); if (sardaukar?.casualties) { sardaukar.casualties.outcome = 'complete'; sardaukar.signature = nexusSardaukarSignature(sardaukar); } observeOccupation(g); if (to.startsWith('homeworld:')) g.homeworldBattleLoss = null; log( g, `${p.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${combatLocationName(g, to)} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`, automatic ? { faction: p.faction, name: 'Battle casualties' } : undefined, ); stageIxSubstitution(g, p, to, cards, losses); finishWinner(g, p, to, cards); } /** Stage faction substitution after actual Tank losses; skills finish before it opens. */ function stageIxSubstitution(g: Game, p: Player, to: string, cards: string[], losses: Record) { const cyborgsLost = Object.values(losses).reduce((sum, count) => sum + count, 0); const survivingSuboids = to.startsWith('homeworld:') ? combatArmy(g, p.id, to).normal : Object.entries(p.forces) .filter(([key]) => splitLocation(key).territory === to) .reduce( (sum, [key, count]) => sum + count - (p.elites?.forces[key] ?? 0), 0, ); if (p.faction === 'ixians' && cyborgsLost > 0 && survivingSuboids > 0) { g.pendingIxSubstitution = { player: p.id, territory: to, losses, cards, ...(to.startsWith('homeworld:') ? { homeworld: { pool: combatArmy(g, p.id, to), cyborgsLost, eliteTanks: p.elites!.tanks, normalTanks: p.tanks - p.elites!.tanks, battleLosses: p.battleLosses, } } : {}), }; if (g.leaderSkills) { const pending = g.pendingIxSubstitution; pending.receipt = { event: g.lastBattleContext!.event, turn: g.turn, physical: sukPhysicalSignature(p), signature: '' }; pending.receipt.signature = ixSubstitutionSignature(pending); g.lastBattleContext!.ixSubstitution = { signature: pending.receipt.signature, completed: false }; } } } function ixSubstitutionSignature(pending: NonNullable) { return JSON.stringify([pending.player, pending.territory, pending.losses, pending.cards, pending.receipt?.event, pending.receipt?.turn, pending.receipt?.physical, ...(pending.sources === undefined ? [] : [pending.sources, pending.recover])]); } function ixSubstitutionIntegrity(g: Game) { const pending = g.pendingIxSubstitution, context = g.lastBattleContext, obligation = context?.ixSubstitution; if (!pending?.receipt && !obligation) { requireRule(!pending || !g.leaderSkills, 'The Ixian skill aftermath lost its substitution receipt.'); return; } if (obligation?.completed) { requireRule(!pending, 'The completed Ixian substitution cannot be replayed.'); return; } const owner = pending && g.players.find(p => p.id === pending.player); requireRule(pending?.receipt && owner?.faction === 'ixians' && obligation && !obligation.completed && context.event === pending.receipt.event && context.turn === pending.receipt.turn && g.turn === context.turn && context.winner === pending.player && context.territory === pending.territory && g.phase === 6 && !g.battle && g.status === 'playing' && pending.receipt.signature === obligation.signature && pending.receipt.signature === ixSubstitutionSignature(pending) && pending.receipt.physical === sukPhysicalSignature(owner) && pending.cards.every(id => owner.hand.some(card => card.id === id)), 'The saved Ixian substitution changed its original battle losses, cards or physical counters.'); const decisions = homeworldSavedDecisions(g); const selecting = decisions.filter(d => d.kind === 'ixSubstitution'); const exchangeResponse = g.response?.kind === 'ixSubstitution' || g.pendingKarama?.use.kind === 'cancel' && g.pendingKarama.use.response.kind === 'ixSubstitution'; requireRule(selecting.every(d => d.player === pending.player && d.territory === pending.territory && JSON.stringify(d.losses) === JSON.stringify(pending.losses)) && (exchangeResponse ? !!pending.sources && !!pending.recover : !pending.sources && !pending.recover) && (exchangeResponse || selecting.length === 1 || decisions.some(d => d.kind === 'rihani' && d.player === pending.player)), 'The saved Ixian substitution lost its original choice or preceding skill exchange.'); } function completeIxSubstitution(g: Game) { g.pendingIxSubstitution = null; if (g.lastBattleContext?.ixSubstitution) g.lastBattleContext.ixSubstitution.completed = true; } function sukForcePool(p: Player, territory: string): SukForceGroup[] { return Object.entries(p.forces).filter(([key]) => splitLocation(key).territory === territory) .map(([key, total]) => ({ key, normal: total - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 })); } function sukPhysicalSignature(p: Player) { return JSON.stringify({ forces: p.forces, elites: p.elites ?? null, reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses }); } function updateSukReceipt(g: Game) { const pending = g.pendingSukRescue!; pending.signature = sukReceiptSignature(pending); g.lastBattleContext!.sukRescue = { signature: pending.signature, completed: false }; } function sukRescueIntegrity(g: Game) { const pending = g.pendingSukRescue, context = g.lastBattleContext; const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'sukRescue'); if (!pending) { requireRule(!decisions.length && (!context?.sukRescue || context.sukRescue.completed), 'The saved Suk Graduate rescue receipt is missing.'); return; } const player = g.players.find((p) => p.id === pending.player); requireRule(player && g.leaderSkills && g.phase === 6 && !g.battle && g.leaderSkills.assignments.some((a) => a.skill === 'suk-graduate' && a.leader === pending.skill.leader) && context?.event === pending.event && context.turn === pending.turn && g.turn === pending.turn && context.result === 'normal' && context.winner === pending.player && context.territory === pending.territory && context.sukRescue?.signature === pending.signature && !context.sukRescue.completed && pending.signature === sukReceiptSignature(pending) && pending.physical === sukPhysicalSignature(player) && (pending.eliteOrigins === undefined || pending.eliteOrigins === true && player.faction === 'ixians') && JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(player, pending.territory)) && pending.commitment.forces.normal === pending.pool.reduce((sum, group) => sum + group.normal, 0) && pending.commitment.forces.elite === pending.pool.reduce((sum, group) => sum + group.elite, 0) && JSON.stringify(pending.commitment.options) === JSON.stringify(casualtyOptions(pending.commitment.forces, pending.commitment.dial, pending.commitment.support)) && pending.commitment.options.length > 0 && pending.cards.every((id) => player.hand.some((c) => c.id === id)), 'The saved Suk Graduate rescue no longer matches its battle, cards or physical forces.'); if (pending.losses) { requireRule(pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(pending.losses)) && decisions.length === 1 && decisions.every((d) => d.player === pending.player && d.event === pending.event && d.territory === pending.territory && d.mode === pending.skill.mode && JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!, pending.eliteOrigins))), 'The saved Suk Graduate rescue choices differ from the committed casualties.'); } else { const lossDecision = homeworldSavedDecisions(g).find((d) => d.kind === 'battleLosses' && d.player === pending.player); const discard = g.pendingTreacheryDiscard?.continuation; requireRule(!decisions.length && (lossDecision ? lossDecision.kind === 'battleLosses' && lossDecision.territory === pending.territory && JSON.stringify(lossDecision.options) === JSON.stringify(pending.commitment.options) && JSON.stringify(lossDecision.cards) === JSON.stringify(pending.cards) : discard?.kind === 'battleResolved' && discard.event === pending.event && JSON.stringify(discard.casualties) === JSON.stringify(pending.commitment)), 'The Suk Graduate rescue lost its casualty continuation.'); } } function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) { const pending = g.pendingSukRescue!; const player = getPlayer(g, pending.player); requireRule(pending.losses && pending.physical === sukPhysicalSignature(player), 'The Suk Graduate rescue no longer has its committed physical counters.'); const quote = quoteSukRescue(pending.skill, pending.pool, pending.losses, option, pending.eliteOrigins); for (const group of quote.removed) { player.forces[group.key] -= group.normal + group.elite; if (!player.forces[group.key]) delete player.forces[group.key]; if (player.elites) { player.elites.forces[group.key] = (player.elites.forces[group.key] ?? 0) - group.elite; if (!player.elites.forces[group.key]) delete player.elites.forces[group.key]; } } player.reserves += quote.reserves.normal + quote.reserves.elite; player.tanks += quote.tanks.normal + quote.tanks.elite; player.battleLosses += quote.tanks.normal + quote.tanks.elite; if (player.elites) { player.elites.reserves += quote.reserves.elite; player.elites.tanks += quote.tanks.elite; } g.pendingSukRescue = null; g.lastBattleContext!.sukRescue!.completed = true; log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${option.kept ? `1 remained in sector ${splitLocation(option.kept.key).sector}` : 'none remained in the battle territory'}, ${quote.reserves.normal + quote.reserves.elite} returned to reserves, and ${quote.tanks.normal + quote.tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' }); observeOccupation(g); if (pending.eliteOrigins) stageIxSubstitution(g, player, pending.territory, pending.cards, quote.eliteTanks!); finishWinner(g, player, pending.territory, pending.cards); } function winnerDiscardSignature(pending: NonNullable) { const { event, turn, territory, player, cards, optional } = pending; return JSON.stringify({ event, turn, territory, player, cards, optional }); } function winnerDiscardObligationSignature(context: NonNullable) { return JSON.stringify({ event: context.event, turn: context.turn, territory: context.territory, player: context.winner, cards: context.winnerDiscards?.cards, completed: context.winnerDiscards?.completed }); } function winnerDiscardsIntegrity(g: Game) { const pending = g.pendingWinnerDiscards; const context = g.lastBattleContext; const obligation = context?.winnerDiscards; if (obligation) { requireRule(obligation.signature === winnerDiscardObligationSignature(context!) && Array.isArray(obligation.cards) && obligation.cards.length > 0 && new Set(obligation.cards).size === obligation.cards.length && typeof obligation.completed === 'boolean' && (obligation.completed ? !pending : !!pending && JSON.stringify(pending.cards) === JSON.stringify(obligation.cards)), 'The saved mandatory winner discard obligation lost its original queue or completion.'); } if (!pending) return; const owner = g.players.find((p) => p.id === pending.player); requireRule(g.status === 'playing' && g.phase === 6 && pending.turn === g.turn && obligation && !obligation.completed && pending.signature === winnerDiscardSignature(pending) && context?.event === pending.event && context.turn === g.turn && context.territory === pending.territory && context.winner === pending.player && owner && Array.isArray(pending.cards) && pending.cards.length > 0 && Array.isArray(pending.optional) && new Set([...pending.cards, ...pending.optional]).size === pending.cards.length + pending.optional.length && [...pending.cards, ...pending.optional].every((id) => typeof id === 'string' && owner.hand.some((card) => card.id === id)), 'The saved mandatory winner discards no longer match their battle or physical cards.'); } function rihaniObligationSignature(context: NonNullable) { return JSON.stringify([context.event, context.turn, context.winner, context.territory, context.rihani?.skill, context.rihani?.cards, context.rihani?.completed, ...(context.rihani?.faceDanceStarted === undefined ? [] : [context.rihani.faceDanceStarted])]); } function pendingRihani(g: Game) { return g.rihaniHistory?.find((r) => r.stage !== 'complete') ?? null; } function rihaniIntegrity(g: Game) { const context = g.lastBattleContext, obligation = context?.rihani; const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'rihani'); const history = g.rihaniHistory ?? [], pending = pendingRihani(g); if (history.length) { requireRule(g.leaderSkills && new Set(history.map((r) => r.event)).size === history.length, 'Rihani history requires its original skill module and distinct battles.'); const universe = nexusTraitorUniverse(g); for (const receipt of history) nexusRule(() => validateRihani(receipt, universe)); } requireRule(history.filter((r) => r.stage !== 'complete').length <= 1, 'Finish the preceding Rihani exchange.'); if (pending) { requireRule(obligation && !obligation.completed && context.event === pending.event && context.winner === pending.owner && context.turn === pending.turn && g.turn === pending.turn && g.status === 'playing' && g.phase === 6 && !g.battle && JSON.stringify(obligation.skill) === JSON.stringify(pending.skill) && JSON.stringify(getPlayer(g, pending.owner).revealedTraitors ?? []) === JSON.stringify(pending.used) && decisions.length === 1 && decisions[0].event === pending.event && decisions[0].player === pending.owner && decisions[0].stage === pending.stage, 'The saved Rihani exchange lost its battle or owned choice.'); nexusRule(() => validateRihani(pending, nexusTraitorUniverse(g), nexusTraitorSnapshot(g))); } else requireRule(!decisions.length, 'The Rihani choice has lost its physical draw receipt.'); if (!obligation) return; requireRule(obligation.faceDanceStarted === undefined || (obligation.faceDanceStarted === true && obligation.completed), 'Invalid saved Rihani aftermath boundary.'); requireRule(obligation.signature === rihaniObligationSignature(context!), 'The saved Rihani victory obligation changed.'); const receipt = history.find((r) => r.event === context!.event); if (obligation.completed) { requireRule(receipt?.stage === 'complete' && receipt.owner === context!.winner && receipt.turn === context!.turn && JSON.stringify(receipt.skill) === JSON.stringify(obligation.skill), 'The completed Rihani victory lost its receipt.'); if (g.phase === 6 && context!.turn === g.turn && !g.battle) { if (obligation.faceDanceStarted) nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), nexusTraitorUniverse(g))); else nexusRule(() => validateRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g))); } } else { const winner = g.players.find((p) => p.id === context!.winner); requireRule(winner && g.status === 'playing' && g.phase === 6 && !g.battle && context!.turn === g.turn && g.leaderSkills?.assignments.some((a) => a.skill === 'rihani-decipherer' && a.leader === obligation.skill.leader) && obligation.cards.every((id) => winner.hand.some((c) => c.id === id)) && (receipt ? receipt === pending : !!g.pendingTreacheryDiscard || !!g.pendingSukRescue || homeworldSavedDecisions(g).some((d) => d.kind === 'battleLosses' && d.player === winner.id)), 'The Rihani victory lost its skilled leader, cards or preceding casualty settlement.'); } } function completeRihaniObligation(g: Game) { g.lastBattleContext!.rihani!.completed = true; g.lastBattleContext!.rihani!.signature = rihaniObligationSignature(g.lastBattleContext!); } function startRihaniVictory(g: Game, winner: Player, territory: string, cards: string[]): boolean { const context = g.lastBattleContext, obligation = context?.rihani; if (!obligation || obligation.completed) return false; requireRule(context.winner === winner.id && context.territory === territory && JSON.stringify(obligation.cards) === JSON.stringify(cards) && !g.rihaniHistory?.some((r) => r.event === context.event), 'Resume the original Rihani victory before winner card cleanup.'); const receipt = nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), { event: context.event, turn: g.turn, owner: winner.id, skill: obligation.skill }, winner.revealedTraitors ?? [], random)); (g.rihaniHistory ??= []).push(receipt); applyNexusTraitorSnapshot(g, receipt.state); if (receipt.peeked.length) log(g, `${winner.name}'s Rihani Decipherer privately inspected two random Traitor Deck cards and shuffled them back. The identities are in that player's private skill history.`, { faction: winner.faction, name: 'Rihani Decipherer' }); if (receipt.stage === 'complete') { completeRihaniObligation(g); if (receipt.skill.skilled && !receipt.eligible.length) log(g, `${winner.name} has no ${winner.faction === 'tleilaxu' ? 'unrevealed Face Dancer' : 'unused held Traitor Card'} to exchange, so Rihani offers no draw.`); return false; } g.decision = { kind: 'rihani', event: receipt.event, player: winner.id, stage: 'offer' }; return true; } function actRihani(g: Game, player: Player, decision: Extract, action: Action) { const receipt = pendingRihani(g), context = g.lastBattleContext!; requireRule(receipt?.owner === player.id && receipt.event === action.event && receipt.event === decision.event, 'Choose only the current Rihani exchange.'); let next: RihaniReceipt; if (decision.stage === 'offer') { requireRule(typeof action.draw === 'boolean' && Object.keys(action).every((key) => ['type', 'event', 'draw'].includes(key)), 'Decide whether to draw before seeing the two new Traitors.'); next = nexusRule(() => chooseRihaniDraw(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g), action.draw as boolean)); } else { requireRule(Array.isArray(action.cards) && action.cards.length === 2 && action.cards.every((id) => typeof id === 'string') && Object.keys(action).every((key) => ['type', 'event', 'cards'].includes(key)), 'Choose one newly drawn and one unused old Traitor.'); const cards = action.cards as string[]; next = nexusRule(() => finishRihani(receipt, nexusTraitorUniverse(g), nexusTraitorSnapshot(g), cards[0], cards[1], random)); } g.rihaniHistory![g.rihaniHistory!.findIndex((r) => r.event === receipt.event)] = next; applyNexusTraitorSnapshot(g, next.state); if (next.stage === 'return') { g.decision = { kind: 'rihani', event: next.event, player: player.id, stage: 'return' }; log(g, `${player.name} drew two private ${player.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} Cards through Rihani and must keep one by revealing an ${player.faction === 'tleilaxu' ? 'unrevealed' : 'unused'} old card.`); } else { completeRihaniObligation(g); if (next.given) { const name = next.given === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : g.players.flatMap((p) => p.leaders).find((l) => l.id === next.given)?.name; log(g, `${player.name} revealed the unused ${name ?? next.given} ${player.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor Card'} for Rihani and returned it with the unkept new card to the shuffled Traitor Deck. The kept card remains private.`); } else log(g, `${player.name} declined Rihani's optional Traitor exchange.`); finishWinner(g, player, context.territory, context.rihani!.cards); } } function projectedRihani(g: Game, owner: string) { if (!g.leaderSkills) return null; const pending = pendingRihani(g); return { history: (g.rihaniHistory ?? []).filter((r) => r.owner === owner).map((r) => ({ event: r.event, turn: r.turn, peeked: [...r.peeked], drawn: [...r.drawn], kept: r.kept, given: r.given, })), pending: pending ? { event: pending.event, owner: pending.owner, stage: pending.stage, ...(pending.owner === owner && pending.stage === 'return' ? { drawn: [...pending.drawn], eligible: [...pending.eligible] } : {}), } : null }; } function finishWinner(g: Game, winner: Player, t: string, cards: string[]) { if (startRihaniVictory(g, winner, t, cards)) return; if (g.pendingIxSubstitution) { const pending = g.pendingIxSubstitution; requireRule(pending.player === winner.id && pending.territory === t && JSON.stringify(pending.cards) === JSON.stringify(cards) && !pending.sources && !pending.recover, 'Resume the original Ixian substitution after Leader Skills.'); g.decision = { kind: 'ixSubstitution', player: winner.id, territory: t, losses: pending.losses }; return; } if (g.pendingWinnerDiscards) { winnerDiscardsIntegrity(g); const pending = g.pendingWinnerDiscards; requireRule(pending.player === winner.id && pending.territory === t && JSON.stringify(pending.optional) === JSON.stringify(cards), 'The mandatory winner discard must resume its original cleanup.'); const entries = pending.cards.map((id) => ({ card: discard(g, winner, id, cleanupDiscardRole(g, winner, id)), discardedBy: winner.id, publicFace: true })); g.pendingWinnerDiscards = null; if (g.lastBattleContext?.winnerDiscards) { g.lastBattleContext.winnerDiscards.completed = true; g.lastBattleContext.winnerDiscards.signature = winnerDiscardObligationSignature(g.lastBattleContext); } stageTreacheryDiscard(g, 'battle:winnerMandatory', entries, { kind: 'winnerMandatoryDiscard', event: pending.event, player: winner.id, territory: t, optional: [...cards], commitment: structuredClone(pending) }); return; } if (cards.length) g.decision = { kind: 'battleCards', player: winner.id, territory: t, cards, }; else finishBattle(g); } /** Pure current compulsory aftermath; no board or phase initialization is executed here. */ function currentBattleAftermathQuote( g: Game, cancel?: AftermathCancellation, spending?: { player: string; card: string }, ) { try { const quote = quoteBattleAftermath( { status: g.status, phase: g.phase, turn: g.turn, advanced: g.advanced, battlePresent: !!g.battle, lastBattle: g.lastBattle, territoryIds: combatLocations(g).map((t) => t.id), context: g.lastBattleContext, players: g.players.map((p) => ({ id: p.id, faction: p.faction, hand: p.hand, leaders: p.leaders, })), leaderIds: [ ...new Set([ ...g.players.flatMap((p) => p.leaders.map((l) => l.id)), ...(g.dukeVidal ? [g.dukeVidal.leader.id] : []), ]), ], physicalCards: physicalTreacheryCards(g), techTokens: g.techTokens, pending: { retention: g.moritaniRetention, income: g.pendingChoamBattleIncome, tech: g.pendingTech, capture: g.pendingCapture, auditor: g.pendingAuditor, faceDance: g.pendingFaceDance, }, cancel, }, spending, ); if (quote.next.kind === 'board') boardResolution(() => quoteCombatBoardContinuation(g)); return quote; } catch (error) { if (error instanceof BattleAftermathQuoteError) throw new RuleError(error.message); throw error; } } function validateAftermathCancellation( g: Game, response: ResponseWindow, spending?: { player: string; card: string }, ) { if ( response.kind === 'capture' || response.kind === 'choamAudit' || response.kind === 'choamBattleIncome' ) return currentBattleAftermathQuote( g, { kind: response.kind, owner: response.owner, intent: response.intent, }, spending, ); } function homeworldVictoryWitness(frame: HomeworldVictoryReturn) { return { event: frame.event, turn: frame.turn, player: frame.player, territory: frame.territory, result: frame.result }; } function homeworldVictoryReturnIntegrity(g: Game) { homeworldRule(() => validateHomeworldVictoryReturn(g)); const frame = g.homeworldVictoryReinforcement; if (frame?.stage === 'arrival' || frame?.stage === 'complete') homeworldRule(() => validateHomeworldArrival(g, frame, 'victoryEvent')); const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const decisions = contexts.flatMap((context) => context?.decision?.kind === 'caladanReinforcement' ? [context.decision] : []); requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event), 'The Caladan choice has lost its original victory.'); if (frame?.stage === 'choice') requireRule(decisions.length > 0, 'The Caladan victory has lost its reinforcement choice.'); if (frame?.stage === 'waiting') requireRule(!!g.pendingTreacheryDiscard || !!g.pendingNullentropy || !!g.truthtrance || !!g.pendingKarama || contexts.some((context) => !!context?.decision || !!context?.response), 'The Caladan victory has lost its preceding battle cleanup choice.'); } function completeHomeworldVictoryReturn(g: Game) { const frame = g.homeworldVictoryReinforcement!; frame.stage = 'complete'; const context = g.lastBattleContext!; requireRule(context.caladanReinforcement?.event === frame.event, 'The Caladan reinforcement has lost its battle completion.'); context.caladanReinforcement.completed = true; context.caladanReinforcement.stage = 'complete'; context.caladanReinforcement.signature = homeworldVictoryObligationSignature(context, true, 'complete'); } function stampHomeworldVictoryStage(g: Game) { const frame = g.homeworldVictoryReinforcement!; const context = g.lastBattleContext!; requireRule(context.caladanReinforcement?.event === frame.event, 'The Caladan reinforcement has lost its original stage obligation.'); context.caladanReinforcement.stage = frame.stage; context.caladanReinforcement.signature = homeworldVictoryObligationSignature(context, context.caladanReinforcement.completed, frame.stage); } function openHomeworldVictoryReturn(g: Game, faceDancePending: boolean) { const frame = g.homeworldVictoryReinforcement; if (!frame || frame.stage === 'complete' || frame.event !== g.lastBattleContext?.event) return false; requireRule(frame.stage === 'waiting', 'Finish the Caladan reinforcement before resuming battle cleanup.'); const quote = homeworldRule(() => quoteHomeworldVictoryReinforcement(g, homeworldVictoryWitness(frame))); if (quote.amount === 0) { frame.destination = 'decline'; frame.ambassadors = []; frame.arrivalSignature = homeworldArrivalSignature(frame); completeHomeworldVictoryReturn(g); if (quote.blocked) log(g, `${getPlayer(g, frame.player).name} received no Caladan victory reinforcement: ${quote.blocked}`); return false; } const blocked = faceDancePending ? 'Caladan reinforcement and Face Dancer replacement await an ordering ruling. You may leave the reserve force at home.' : quote.blocked; const offer = { population: quote.population, survivors: quote.survivors, blocked }; frame.offer = { ...offer, signature: homeworldVictoryOfferSignature(frame, offer) }; frame.stage = 'choice'; stampHomeworldVictoryStage(g); g.decision = { kind: 'caladanReinforcement', player: frame.player, event: frame.event }; return true; } function decideHomeworldVictoryReturn(g: Game, p: Player, action: Action) { const frame = g.homeworldVictoryReinforcement; requireRule(frame?.stage === 'choice' && frame.player === p.id && frame.event === action.event, 'Choose the current Caladan victory reinforcement.'); if (action.decline === true) { requireRule(action.amount === undefined && action.destination === undefined, 'Leaving the reserve force at home does not select a quantity or destination.'); frame.destination = 'decline'; frame.ambassadors = []; frame.arrivalSignature = homeworldArrivalSignature(frame); completeHomeworldVictoryReturn(g); log(g, `${p.name} left the optional Caladan victory reinforcement in reserves.`); finishBattle(g); return; } requireRule(!frame.offer?.blocked, frame.offer?.blocked ?? 'This reinforcement needs its ordering ruling.'); requireRule((action.amount === undefined || action.amount === 1) && typeof action.destination === 'string', 'Caladan adds exactly one reserve force to the battle location.'); const quote = homeworldRule(() => quoteHomeworldVictoryReinforcementDestination(g, homeworldVictoryWitness(frame), action.destination as string)); const seat = quote.transfer.players.find((seat) => seat.id === p.id)!; p.reserves = seat.reserves; g.homeworlds!.custody = quote.transfer.state; observeOccupation(g); frame.destination = quote.selected.id; frame.stage = 'arrival'; frame.ambassadors = []; frame.arrivalSignature = homeworldArrivalSignature(frame); stampHomeworldVictoryStage(g); if (quote.selected.territory) { place(p, quote.selected.territory, quote.selected.sector!, 1); openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, 1, 0, 'caladanReinforcement'); if (g.pendingAmbassador) { g.pendingAmbassador.victoryEvent = frame.event; appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador); } } log(g, `${p.name} added one force from Caladan reserves to ${quote.selected.name} after winning the battle. The force joins an existing surviving army; no spice or shipment allowance was spent.`, { faction: p.faction, name: 'Caladan victory reinforcement' }); } function resumeHomeworldVictoryReturn(g: Game) { const frame = g.homeworldVictoryReinforcement; if (frame?.stage !== 'arrival' || g.pendingTreacheryDiscard || g.pendingNullentropy || g.truthtrance || g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador || g.pendingTerrorEntry || g.pendingKarama || g.decision || g.response) return; completeHomeworldVictoryReturn(g); finishBattle(g); } function projectedHomeworldVictoryReturn(g: Game, player: string) { const frame = g.homeworldVictoryReinforcement; if (frame?.stage !== 'choice') return null; const quote = homeworldRule(() => quoteHomeworldVictoryReinforcement(g, homeworldVictoryWitness(frame))); return { event: frame.event, player: frame.player, territory: frame.territory, amount: 1 as const, blocked: frame.offer?.blocked ?? quote.blocked, destinations: player === frame.player ? quote.destinations : [], }; } function finishBattle(g: Game) { const quote = currentBattleAftermathQuote(g); for (const step of quote.steps) { if (step.kind === 'transferTech') { transferTech(g, step.id, step.to); g.pendingTech = null; } else if (step.kind === 'emptyAuditor') { log( g, 'The Auditor has no eligible opposing hand cards to inspect. Cards used in this battle are excluded.', { faction: 'choam', name: 'Auditor: no eligible cards' }, ); g.pendingAuditor = null; } } const next = quote.next; const rihani = g.lastBattleContext?.rihani; if (next.kind === 'faceDance' && rihani && !rihani.faceDanceStarted) { // Winner cleanup is complete. Face Dance may now reveal or recycle cards; // the earlier exchange remains immutable history, not current deck custody. requireRule(rihani.completed, 'Finish Rihani before Face Dance.'); rihaniIntegrity(g); rihani.faceDanceStarted = true; rihani.signature = rihaniObligationSignature(g.lastBattleContext!); } if ((next.kind === 'faceDance' || next.kind === 'board') && openHomeworldVictoryReturn(g, next.kind === 'faceDance')) return; const choamInspection = g.lastBattleContext?.nexusChoamInspection; if (next.kind === 'board' && choamInspection?.stage === 'pending') { choamInspection.stage = 'offer'; choamInspection.signature = nexusChoamInspectionSignature(choamInspection); g.decision = { kind: 'nexusChoamInspection', player: g.lastBattleContext!.winner!, event: choamInspection.event, }; return; } if (next.kind === 'moritaniRetention' && next.source === 'nexus') { const receipt = g.lastBattleContext?.nexusMoritaniRetention; requireRule(!!receipt && receipt.event === next.event && ['pending', 'offer'].includes(receipt.stage), 'The Moritani Nexus loser window lost its battle.'); receipt.stage = 'offer'; receipt.signature = nexusMoritaniRetentionSignature(receipt); } if (next.kind === 'choamBattleIncome') { g.response = { ...next, passed: [] }; } else if (next.kind === 'choamAudit') { g.decision = { kind: next.kind, player: next.player, event: next.event }; } else if (next.kind !== 'board') { g.decision = { ...next }; } else { const remaining = battles(g); if (remaining.length) g.active = remaining[0].chooser; else nextPhase(g); } } function currentAuditCount(g: Game) { const pending = g.pendingAuditor; return pending ? auditCount( getPlayer(g, pending.opponent).hand, pending.usedCards, pending.survived, ) : 0; } function finishAuditor( g: Game, outcome: 'inspect' | 'pay' | 'decline' | 'cancel' | 'empty', ) { const pending = g.pendingAuditor; requireRule(pending, 'This Auditor opportunity has already ended.'); const owner = getPlayer(g, pending.owner), opponent = getPlayer(g, pending.opponent); const count = currentAuditCount(g); if (outcome === 'pay') { requireRule( count > 0 && opponent.spice >= count, 'Pay the full current audit cost or allow the inspection.', ); opponent.spice -= count; owner.spice += count; log( g, `${opponent.name} paid ${count} spice directly to ${owner.name} to cancel the entire Auditor inspection.`, ); } else if (outcome === 'inspect' && count) { const cards = sampleAuditCards( opponent.hand, pending.usedCards, pending.survived, random, ); g.auditorInsight = { event: pending.event, viewer: owner.id, target: opponent.id, turn: g.turn, cards, }; log( g, `${owner.name}'s ${pending.survived ? 'surviving' : 'killed'} Auditor privately inspected ${cards.length} random opposing hand card${cards.length === 1 ? '' : 's'}. Cards used in this battle were excluded; no cards changed hands.`, { faction: 'choam', name: 'Auditor inspection' }, ); } else { log( g, outcome === 'decline' ? `${owner.name} declined the Auditor inspection.` : outcome === 'cancel' ? 'Karama canceled the Auditor inspection; no cancellation payment was charged.' : 'The Auditor has no eligible opposing hand cards to inspect.', { faction: 'choam', name: outcome === 'cancel' ? 'Auditor canceled' : 'Auditor', }, ); } g.pendingAuditor = null; g.decision = null; finishBattle(g); } function auditorIntegrity(g: Game) { const pending = g.pendingAuditor; if (!pending) { requireRule( !['choamAudit', 'choamAuditPayment'].includes(g.decision?.kind ?? '') && g.response?.kind !== 'choamAudit', 'The Auditor continuation is missing.', ); return; } requireRule( g.advanced && g.status === 'playing' && g.phase === 6 && !g.battle && pending.turn === g.turn && typeof pending.event === 'string' && !!pending.event && pending.owner !== pending.opponent && g.lastBattle.length === 2 && g.lastBattle.includes(pending.owner) && g.lastBattle.includes(pending.opponent) && combatLocations(g).some((t) => t.id === pending.territory) && g.players.some( (p) => p.id === pending.owner && p.faction === 'choam' && p.leaders.some(isAuditorLeader), ) && g.players.some((p) => p.id === pending.opponent) && typeof pending.survived === 'boolean' && ['offer', 'response', 'payment'].includes(pending.stage) && Array.isArray(pending.usedCards) && pending.usedCards.every((id) => typeof id === 'string') && new Set(pending.usedCards).size === pending.usedCards.length, 'The saved Auditor opportunity does not match this resolved battle.', ); // Modern saves retain an independent receipt of the resolved battle. Legacy // opportunities without that receipt still use the controls checked below. if (g.lastBattleContext) requireRule( g.lastBattleContext.event === pending.event && g.lastBattleContext.turn === pending.turn && g.lastBattleContext.territory === pending.territory && JSON.stringify(g.lastBattleContext.combatants) === JSON.stringify(g.lastBattle), 'The saved Auditor opportunity differs from its completed battle receipt.', ); // These overlays preserve the exact parent decision/response instead of ending it. const contexts = [ g, g.pendingNullentropy?.resume, g.pendingTreacheryDiscard?.continuation.kind === 'nullentropyDiscard' || g.pendingTreacheryDiscard?.continuation.kind === 'truthtranceDiscard' ? g.pendingTreacheryDiscard.continuation.resume : undefined, g.pendingRicheseGift?.resume, ].filter((c) => !!c); const decisions = contexts.flatMap((c) => (c.decision ? [c.decision] : [])); const responses = contexts.flatMap((c) => [ ...(c.response ? [c.response] : []), ...(c.pendingKarama?.use.kind === 'cancel' ? [c.pendingKarama.use.response] : []), ]); for (const decision of decisions.filter( (d) => d.kind === 'choamAudit' || d.kind === 'choamAuditPayment', )) { const offer = decision.kind === 'choamAudit'; requireRule( decision.event === pending.event && decision.player === (offer ? pending.owner : pending.opponent) && pending.stage === (offer ? 'offer' : 'payment'), 'This Auditor decision is stale.', ); } for (const response of responses.filter((r) => r.kind === 'choamAudit')) requireRule( response.owner === pending.owner && response.intent === pending.event && pending.stage === 'response', 'This Auditor cancellation window is stale.', ); requireRule( pending.stage === 'offer' || (pending.stage === 'response' && responses.some((r) => r.kind === 'choamAudit')) || (pending.stage === 'payment' && decisions.some((d) => d.kind === 'choamAuditPayment')), 'The saved Auditor response or payment continuation is missing.', ); } /** Older saved cleanup decisions predate the resolved battle receipt. Their * authenticated parent supplies the territory/winner; no battle is replayed. */ function battleCleanupInput(g: Game) { return { phase: g.phase, turn: g.turn, battlePresent: !!g.battle, lastBattle: g.lastBattle, playerIds: g.players.map((p) => p.id), territoryIds: combatLocations(g).map((t) => t.id), context: g.lastBattleContext, }; } function battlePreflight(quote: () => T): T { try { return quote(); } catch (error) { if (error instanceof KaramaBattlePreflightError) throw new RuleError(error.message); throw error; } } function validateMoritaniCancellation(g: Game, responseOwner: string) { const pending = g.moritaniRetention; return battlePreflight(() => preflightMoritaniRetentionCancellation({ ...battleCleanupInput(g), responseOwner, pending, hand: g.players.find((p) => p.id === pending?.player)?.hand ?? [], physicalCards: physicalTreacheryCards(g), }), ); } function cleanupBattleContext(g: Game, to: string, winner: string | null) { const quote = battlePreflight(() => validateBattleCleanupContext({ ...battleCleanupInput(g), territory: to, winner, }), ); if (quote.kind === 'legacy') g.lastBattleContext = { ...quote.seed, event: g.pendingAuditor?.event ?? crypto.randomUUID(), }; return g.lastBattleContext!; } function finishMoritaniRetention(g: Game, keep: string | null) { const pending = g.moritaniRetention; if (keep === null && pending?.stage === 'response') validateMoritaniCancellation(g, pending.owner); requireRule( pending && pending.turn === g.turn && g.phase === 6, 'This battle card cleanup is no longer current.', ); const player = getPlayer(g, pending.player); requireRule( keep === null || pending.eligible.includes(keep), 'Choose one eligible card from your played battle cards.', ); requireRule( pending.played.every((id) => player.hand.some((c) => c.id === id)), 'Cards committed to alliance cleanup must remain available.', ); if (pending.source === 'nexus') { const receipt = g.lastBattleContext?.nexusMoritaniRetention; requireRule(receipt?.stage === 'offer' && receipt.event === pending.event && receipt.player === player.id && pending.stage === 'choose', 'Finish the original losing battle before using Moritani Nexus.'); if (keep !== null) { const offer = quoteNexusMoritaniRetention(g, player.id, { kind: 'moritaniRetention', owner: pending.owner, player: player.id, territory: pending.territory, cards: pending.eligible, source: 'nexus', event: pending.event, }); requireRule(offer?.canKeep, 'Moritani Secret Ally needs its unallied holder and a retainable played card.'); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, player.id, g.players)); } receipt.kept = keep; receipt.stage = 'complete'; receipt.signature = nexusMoritaniRetentionSignature(receipt); } const discarded = pending.played .filter((id) => id !== keep) .map((id) => ({ card: discard(g, player, id, cleanupDiscardRole(g, player, id)), discardedBy: player.id, publicFace: true, })); log( g, keep ? `${player.name} retained ${cardOf(player, keep)!.name} through ${pending.source === 'nexus' ? 'Moritani Nexus Secret Ally' : 'the Moritani alliance'}.` : `${player.name} retained no battle card through ${pending.source === 'nexus' ? 'the postbattle Nexus choice' : 'the Moritani alliance'}.`, ); g.moritaniRetention = null; const context = discarded.length ? cleanupBattleContext( g, pending.territory, g.lastBattle.find((id) => id !== player.id) ?? null, ) : null; if (discarded.length) stageTreacheryDiscard(g, 'battle:moritani', discarded, { kind: 'battleCleanup', event: context!.event, combatants: [...g.lastBattle], territory: pending.territory, player: player.id, source: 'moritani', kept: keep ? [keep] : [], played: [...pending.played], retention: pending, }); else finishBattle(g); } function currentHomeworldRevivalGrant( g: Game, p: Player, source: HomeworldRevivalDeploymentSource, group: HomeworldRevivalDeploymentGroup, ) { if (!g.homeworlds?.custody || !['fremen', 'tleilaxu'].includes(p.faction)) return null; const deposit = homeworldRule(() => quoteNativeRevivalDeposit( homeworldContext(g), g.homeworlds!.custody!, p.id, { normal: group.amount - group.elite, elite: group.elite }, )); const seat = deposit.players.find((seat) => seat.id === p.id)!; const after = { advanced: g.advanced, homeworlds: { custody: deposit.state }, players: g.players.map((other) => other.id !== p.id ? other : { ...p, reserves: seat.reserves, ...(p.elites ? { elites: { ...p.elites, reserves: seat.eliteReserves } } : {}), }), }; return homeworldRule(() => quoteHomeworldRevivalDeployment(g, after, p.id, source, group)); } function requireHomeworldRevivalGrant( g: Game, p: Player, source: HomeworldRevivalDeploymentSource, group: HomeworldRevivalDeploymentGroup, ) { requireRule(!g.homeworldRevivalReturn || g.homeworldRevivalReturn.stage === 'complete', 'Finish the previous revived group’s placement before another revival.'); const grant = currentHomeworldRevivalGrant(g, p, source, group); requireRule(!grant?.blocked, grant?.blocked ?? 'This revival placement needs its timing ruling.'); return grant; } function stageHomeworldRevivalReturn( g: Game, p: Player, source: HomeworldRevivalDeploymentSource, group: HomeworldRevivalDeploymentGroup, grant: ReturnType, card?: string, ) { if (!grant) return; g.homeworldRevivalReturn = homeworldRule(() => makeHomeworldRevivalReturn({ event: crypto.randomUUID(), turn: g.turn, phase: g.phase, player: p.id, source, group, quote: grant, ...(card ? { card } : {}), })); stampHomeworldRevivalProgress(g); } function stampHomeworldRevivalProgress(g: Game) { const frame = g.homeworldRevivalReturn!; if (frame.progressVersion === 1) g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame); } function homeworldRevivalReturnIntegrity(g: Game) { const frame = g.homeworldRevivalReturn; homeworldRule(() => validateHomeworldRevivalReturn(g, frame)); const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const decisions = contexts.flatMap((context) => context?.decision?.kind === 'homeworldRevivalDeployment' ? [context.decision] : []); requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event), 'The revival placement decision has lost its original returned group.'); if (frame?.stage === 'choice') requireRule(decisions.length > 0, 'The returned group has lost its owned placement choice.'); } function completeHomeworldRevivalReturn(g: Game) { const frame = g.homeworldRevivalReturn!; requireRule(!frame.ambassadors?.some((entry) => !entry.completed), 'Finish the revived group’s Ambassador opportunity before its original revival income.'); frame.stage = 'complete'; stampHomeworldRevivalProgress(g); g.response = structuredClone(frame.resumeResponse ?? null); } function resumeHomeworldRevivalReturn(g: Game) { const frame = g.homeworldRevivalReturn; if (!frame || frame.stage === 'complete' || frame.stage === 'choice' || g.pendingTreacheryDiscard || g.pendingNullentropy || g.truthtrance || g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador || g.pendingTerrorEntry || g.pendingKarama || g.decision) return; if (frame.stage === 'arrival') { if (!g.response) completeHomeworldRevivalReturn(g); return; } // The original revival/card has committed. Hold its independent income until // the optional physical placement and any Ambassador children are complete. requireRule(!g.response || g.response.kind === 'revivalIncome', 'Finish the original revival continuation before placement.'); frame.resumeResponse = structuredClone(g.response); frame.resumeSignature = homeworldRevivalResumeSignature(frame.resumeResponse); g.response = null; frame.stage = 'choice'; stampHomeworldRevivalProgress(g); const destinations = homeworldRule(() => homeworldRevivalDestinations(g, frame.player, frame.quote)); if (!destinations.length || destinations.every((d) => d.blocked && !d.blocked.includes('await'))) { frame.destination = 'decline'; frame.ambassadors = []; frame.arrivalSignature = homeworldRevivalArrivalSignature(frame); completeHomeworldRevivalReturn(g); log(g, `${getPlayer(g, frame.player).name} left the revived group in reserves because no placement destination is available.`); return; } g.decision = { kind: 'homeworldRevivalDeployment', player: frame.player, event: frame.event }; } function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) { const frame = g.homeworldRevivalReturn; requireRule(frame?.stage === 'choice' && frame.player === p.id && action.event === frame.event, 'Choose the current returned group’s placement.'); if (action.decline === true) { requireRule(action.destination === undefined && action.amount === undefined, 'Leaving the group in reserves does not select a destination or quantity.'); frame.destination = 'decline'; frame.ambassadors = []; frame.arrivalSignature = homeworldRevivalArrivalSignature(frame); completeHomeworldRevivalReturn(g); log(g, `${p.name} left the newly revived group in reserves.`); return; } const amount = frame.quote.normal + frame.quote.elite; requireRule((action.amount === undefined || action.amount === amount) && typeof action.destination === 'string', 'Place the entire eligible revived group in one destination.'); const quote = homeworldRule(() => quoteHomeworldRevivalDestination(g, p.id, frame.quote, action.destination as string)); const seat = quote.transfer.players.find((seat) => seat.id === p.id)!; p.reserves = seat.reserves; if (p.elites) p.elites.reserves = seat.eliteReserves; g.homeworlds!.custody = quote.transfer.state; observeOccupation(g); frame.destination = quote.selected.id; frame.stage = 'arrival'; stampHomeworldRevivalProgress(g); frame.ambassadors = []; frame.arrivalSignature = homeworldRevivalArrivalSignature(frame); if (quote.selected.territory) { place(p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite); openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite, 'homeworldRevival'); if (g.pendingAmbassador) { g.pendingAmbassador.revivalEvent = frame.event; appendHomeworldRevivalAmbassador(frame, g.pendingAmbassador); } } log(g, `${p.name} placed ${amount} newly revived ${frame.quote.kind === 'fedaykin' ? 'Fedaykin' : 'free forces'} in ${quote.selected.name}. No additional revival payment or shipment allowance was used.`, { faction: p.faction, name: frame.quote.kind === 'fedaykin' ? 'Southern Hemisphere revival' : 'Tleilax free revival' }); } function projectedHomeworldRevivalReturn(g: Game, player: string) { const frame = g.homeworldRevivalReturn; if (frame?.stage !== 'choice') return null; return { event: frame.event, player: frame.player, kind: frame.quote.kind, normal: frame.quote.normal, elite: frame.quote.elite, blocked: frame.quote.blocked, destinations: player === frame.player ? homeworldRule(() => homeworldRevivalDestinations(g, player, frame.quote)) : [], }; } function homeworldRevivalChoiceBlocks(g: Game, p: Player) { const blocks: { source: 'normal' | 'ghola'; amount: number; elite: number; reason: string }[] = []; if (!g.homeworlds?.custody || !['fremen', 'tleilaxu'].includes(p.faction)) return blocks; for (const source of ['normal', 'ghola'] as const) { const maximum = source === 'normal' ? forceRevivalRemaining(g, p) : Math.min(5, p.tanks); for (let amount = 1; amount <= maximum; amount++) for (let elite = Math.max(0, amount - (p.tanks - (p.elites?.tanks ?? 0))); elite <= Math.min(amount, eliteRevivalRemaining(p, g.advanced)); elite++) { const free = source === 'normal' ? forceRevivalQuote(g, p, amount, elite).free : 0; const grant = currentHomeworldRevivalGrant(g, p, source, { amount, elite, free }); if (grant?.blocked) blocks.push({ source, amount, elite, reason: grant.blocked }); } } return blocks; } function collectRevivalIncome( g: Game, actor: Player, paid: number, free = false, ghola = false, ) { const tleilaxu = byFaction(g, 'tleilaxu'); if (!tleilaxu) return; g.revivalFreeIncome ??= {}; const reward = ghola || (free && g.revivalFreeIncome[actor.id] !== g.turn && !( actor.id !== tleilaxu.id && homeworldRule(() => tleilaxuHomeworldFreeIncomeBlocked(g)) )); if (free && !ghola) g.revivalFreeIncome[actor.id] = g.turn; const amount = (actor.id === tleilaxu.id ? 0 : paid) + (reward ? 1 : 0); if (amount) g.response = { kind: 'revivalIncome', owner: tleilaxu.id, recipient: actor.id, amount, passed: [], }; } function currentRevivalResumeQuote(g: Game, stage: 'stop' | 'finish') { try { return quoteRevivalResume(g, stage); } catch (error) { if (error instanceof RevivalResumeError) throw new RuleError(error.message); throw error; } } function finishRevival( g: Game, quote: RevivalResumeQuote = currentRevivalResumeQuote(g, 'finish'), ) { const revival = quote.pending; g.pendingRevival = revival; const p = getPlayer(g, revival.player); if (quote.outcome === 'response') { revival.checks.shift(); g.response = quote.nextResponse!; return; } const payer = revival.payer ? getPlayer(g, revival.payer) : p; if (quote.outcome === 'fremenLimit') { g.pendingRevival = null; log( g, `${p.name}'s normal revival was not completed: the request exceeds their current force-revival allowance. No forces or spice changed.`, ); return; } if (quote.outcome === 'unfunded') { g.pendingRevival = null; log( g, `${p.name}'s revival was not completed because its current price cannot be paid.`, ); return; } const deploymentGrant = revival.kind === 'forces' ? requireHomeworldRevivalGrant(g, p, revival.emperorExtra ? 'emperorExtra' : 'normal', { amount: revival.amount!, elite: revival.elite ?? 0, free: revival.free, }) : null; payer.spice -= revival.cost; if (revival.kind === 'forces') { const n = revival.amount!, elite = revival.elite ?? 0; const source = revival.emperorExtra ? 'emperorExtra' : 'normal'; const group = { amount: n, elite, free: revival.free }; addRevivedReserves(g, p, n, elite); p.tanks -= n; if (revival.emperorExtra) g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n; else { // Preserve unknown legacy usage instead of guessing how earlier paid returns were priced. if (p.freeForcesRevived !== undefined || p.revived === 0) p.freeForcesRevived = (p.freeForcesRevived ?? 0) + revival.free; p.revived += n; } if (p.elites) { p.elites.tanks -= elite; p.elites.revived += elite; } if (quote.techIncome) { const income = quote.techIncome; const token = g.techTokens![income.id]; token.triggeredTurn = income.triggeredTurn; token.spice = income.spice; const rule = TECH_TOKENS.find((t) => t.id === income.id)!; log( g, `${rule.name} accrued ${token.spice} spice for ${getPlayer(g, income.owner).name}, payable at phase end.`, ); } log(g, `${p.name} revived ${n} forces.${g.homeworlds ? ` ${revival.free} were free; ${n - revival.free} were paid. ${payer.name} paid ${revival.cost} spice. Future revival requests use the resulting Homeworld population.` : ''}`, { faction: p.faction, name: 'Revival' }); stageHomeworldRevivalReturn(g, p, source, group, deploymentGrant); } else { if (revival.kind === 'kwisatz') { p.kwisatz!.dead = false; p.kwisatz!.revivalCycle = Math.max(p.revivalCycle, p.kwisatz!.revivalCycle ?? 1) + 1; log(g, `${p.name} revived Kwisatz Haderach.`, { faction: p.faction, name: 'Revival' }); } else { const sharedDuke = revival.kind === 'leader' && revival.leader === DUKE_VIDAL_ID; const leader = sharedDuke ? g.dukeVidal!.leader : g.players.flatMap((owner) => owner.leaders).find((l) => l.id === revival.leader)!; if (revival.kind === 'foreignGhola') leader.gholaBy = p.id; else delete leader.gholaBy; leader.dead = false; delete leader.concealed; if (sharedDuke) g.dukeVidal = consumeDuke(g.dukeVidal!); // Automatic Karama response chains may commit after the action's before/after if (revival.kind === 'leader' && g.leaderSkills && !isAuditorLeader(leader) && !leader.capturedBy && p.leaders.some(own => own.id === leader.id)) { requireLeaderSkillRevival(g, p.id); g.leaderSkills = skillRule(() => offerRevivedLeaderSkill( g.leaderSkills!, p.id, leader.id, crypto.randomUUID())); } log( g, `${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.${sharedDuke ? ' The living shared disc is set aside, unclaimed.' : ''}`, { faction: p.faction, name: 'Revival' }, ); } p.leaderRevived = true; if (g.revivalRequests) delete g.revivalRequests[p.id]; } g.pendingRevival = null; if (quote.freeIncome) { g.revivalFreeIncome ??= {}; g.revivalFreeIncome[quote.freeIncome.player] = quote.freeIncome.turn; } if (quote.nextResponse) { g.response = quote.nextResponse; if (quote.nextResponse.kind === 'revivalIncome') Object.assign(g.response, stampBureaucratPayment(g,'revival',payer.id,quote.nextResponse.owner,revival.cost)); } } function beginRevival(g: Game, revival: PendingRevival) { if (revival.kind === 'leader') requireLeaderSkillRevival(g,revival.player); requireRule( !revivalPrevented(g, revival.player), 'Tleilaxu prevented this faction’s normal revivals for this turn.', ); if (revival.kind === 'forces') requireHomeworldRevivalGrant(g, getPlayer(g, revival.player), revival.emperorExtra ? 'emperorExtra' : 'normal', { amount: revival.amount!, elite: revival.elite ?? 0, free: revival.free }); g.pendingRevival = revival; const choam = byFaction(g, 'choam'); if ( choam && choam.id !== revival.player && revival.free > 0 && !revival.emperorExtra ) { g.decision = { kind: 'choamFreeRevival', player: choam.id, recipient: revival.player, }; return; } offerRevivalStop(g); } function offerRevivalStop( g: Game, quote: RevivalResumeQuote = currentRevivalResumeQuote(g, 'stop'), ) { if (quote.outcome === 'decision') { g.pendingRevival = quote.pending; g.decision = quote.nextDecision!; } else finishRevival(g, quote); } function currentChoamWorthlessCancellationQuote( g: Game, response: ResponseWindow, ) { try { const declaration = quoteChoamWorthlessCancellation(g, response); if (!declaration) return null; const storm = declaration.resume.kind === 'storm' ? currentChoamStormQuote(g) : null; const revival = declaration.resume.kind === 'revival' ? currentRevivalResumeQuote(g, 'stop') : null; if (declaration.resume.kind === 'movement') { const move = g.pendingChoamMove!; if (move.source === 'ambassador') currentFremenAmbassador(g, move.ambassadorEvent); let valid = true; try { validateMovementOrder(g, getPlayer(g, move.player), move); } catch (error) { if (!(error instanceof RuleError)) throw error; valid = false; } if (valid) { if (move.source === 'ambassador') validateAmbassadorRelocationArrival(g, move); else validateMovementArrival(g, move, true); } } return { declaration, storm, revival }; } catch (error) { if (error instanceof ChoamWorthlessCancellationError) throw new RuleError(error.message); throw error; } } function moritaniAllianceCancellationQuote(g: Game, response: ResponseWindow) { try { return quoteMoritaniAllianceCancellation(g, response); } catch (error) { if (error instanceof MoritaniAllianceCancellationError) throw new RuleError(error.message); throw error; } } function currentRicheseCancellationQuote(g: Game, response: ResponseWindow) { try { const quote = quoteRicheseCancellation(g, response); if (!quote) return null; // Only restore the controls saved by this producer; all other pending // records remain live. No normalization, response resolution or RNG. const shadow: Game = { ...g, ...quote.resume }; if (quote.kind === 'richeseGift') shadow.pendingRicheseGift = null; else shadow.pendingRichesePurchaseIncome = null; const controls = { response: shadow.response, decision: shadow.decision, pendingKarama: shadow.pendingKarama, phaseOpening: shadow.phaseOpening, }; suspendedControlsIntegrity(shadow, controls); if (shadow.response) { karamaCanceledSource(shadow, shadow.response); // These pure contracts validate the declared parent; their canceled // outcomes are not applied and no future response is answered here. terminalCancellationQuote(shadow, shadow.response); if (isCombatResponseKind(shadow.response.kind)) currentCombatResponseQuote(shadow, { kind: 'response', response: { kind: shadow.response.kind, owner: shadow.response.owner, }, canceled: false, }); } return quote; } catch (error) { if (error instanceof RicheseCancellationError) throw new RuleError(error.message); throw error; } } function currentVictoryQuote(g: Game) { try { return quoteVictory(g); } catch (error) { if (error instanceof VictoryQuoteError) throw new RuleError(error.message); throw error; } } function currentPlacementCancellationQuote(g: Game, response: ResponseWindow) { try { const receipt = quotePlacementCancellation(g, response); if (!receipt) return null; let finalVictory: VictoryQuote | null = null; if (receipt.kind === 'ecazPlacement') { // Revival settlement preceded the placement opportunity. Only phase5 // initialization remains; a real Ix opening defers its future effects. movementPhaseQuote(() => quoteMovementPhaseStart( { ...g, phase: 5 }, g.expansions.includes('ix'), ), ); } else if (receipt.kind === 'moritaniPlacement') { // Mentat opening income is already settled. CHOAM retains its own later // market/Mentat choice; otherwise this receipt reaches victory now. if (!byFaction(g, 'choam')) finalVictory = currentVictoryQuote(g); } else if (!byFaction(g, 'choam')) { const resources = currentPhaseResources(g); if (!g.expansions.includes('ix')) { const board = boardResolution(() => quoteCombatBoard(g)); if (!board.battles.length) { // No combat: the actual Battle initializer departs again to // collection. Project only settled public balances and empty aid. boardResolution(() => quoteBattlePhaseAdvance({ advanced: g.advanced, storm: g.storm, order: g.order, mobileStronghold: g.mobileStronghold, spice: g.spice, expansions: g.expansions, aid: {}, players: g.players.map((p) => ({ id: p.id, faction: p.faction, ally: p.ally, advisors: p.advisors, forces: p.forces, elites: p.elites, noField: p.noField, spice: resources.balances[p.id], })), }), ); } } } return { receipt, finalVictory }; } catch (error) { if (error instanceof PlacementCancellationError) throw new RuleError(error.message); throw error; } } function completeChoamKulon(g: Game, owner: Player) { g.choamMovement = { turn: g.turn, bonus: (g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) + 1, }; log(g, `${owner.name} used Kulon to move one extra territory this turn.`); } function completeChoamLaLaLa(g: Game, target: string) { g.revivalRules ??= newRevivalRules(); (g.revivalRules.freeBlocked ??= []).push(target); log(g, `${getPlayer(g, target).name} cannot take free force revival this phase.`); } function completeChoamBaliset(g: Game, target: string, location: string) { (g.choamBaliset ??= []).push({ turn: g.turn, player: target, territory: location }); log(g, `${getPlayer(g, target).name} cannot move into CHOAM's ${territory(location).name} this phase; shipment remains possible.`); } function completeChoamGamont( g: Game, owner: Player, targetId: string, from: string, elite: number, noFieldEvent?: string, ) { const target = getPlayer(g, targetId); if (noFieldEvent) revealPlayerNoField(g, target, 'gamont'); const returned = (target.forces[from] ?? 0) > 0; if (returned) { removeGroup(target, [[from, 1]], { [from]: elite }); target.reserves++; if (target.elites) target.elites.reserves += elite; } observeOccupation(g); settleAdvisors(g); log(g, returned ? `${owner.name} sent one of ${target.name}’s forces to reserves with Trip to Gamont.` : `${owner.name} used Trip to Gamont to reveal ${target.name}’s No-Field. No force was present to return; the card is still used.`); } function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) { karamaConversionIntegrity(g); currentFactionPayment(g); const response = g.response!; if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response); if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response); if (response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, response); if (response.kind === 'nexusFremenCunning') currentFremenCunningResponse(g, response); if (response.kind === 'nexusEcazBetrayal') currentEcazBetrayalResponse(g, response); if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response); if (response.kind === 'guildRate') { if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, response); else validateGuildRateResponse(g, response); } if (response.kind === 'moritaniPlacement') { // Both outcomes need the same physical inventory and declared source. // This detached denial quote is only validation here; allowance still // executes placeTerror and checks current placement feasibility below. try { quotePlacementCancellation(g, response); } catch (error) { if (error instanceof PlacementCancellationError) throw new RuleError(error.message); throw error; } } const ecazCollectionQuote = response.kind === 'ecazCollection' ? currentEcazCollectionQuote(g, response, canceled) : null; if (response.source === 'ambassador') validateAmbassadorPurchaseResponse(g, response); const placementCancellation = canceled ? currentPlacementCancellationQuote(g, response) : null; const choamCancellation = canceled ? currentChoamWorthlessCancellationQuote(g, response) : null; const auctionCancellation = canceled ? validateAuctionContinuationCancellation(g, response) : null; const technologyCancellation = canceled ? auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response)) : null; const richeseCancellation = canceled ? currentRicheseCancellationQuote(g, response) : null; if (canceled) validateDisasterCancellation(g, response); if (canceled) validateAftermathCancellation(g, response); const terminal = canceled ? terminalCancellationQuote(g, response) : null; const allianceCancellation = canceled ? moritaniAllianceCancellationQuote(g, response) : null; if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return; g.response = null; if (response.kind === 'guildRate') { if (g.pendingGuildTransport) finishGuildTransportRateResponse(g, response, canceled); else finishGuildRateResponse(g, response, canceled); return; } if (response.kind === 'nexusEcazBetrayal') { finishEcazBetrayalResponse(g, response, canceled); return; } if (response.kind === 'nexusFremenCunning') { finishFremenCunningResponse(g, response, canceled); return; } if (response.kind === 'nexusGuildCunning') { settleGuildCunning(g,response,canceled); return; } if (response.kind === 'nexusAdvisorFlip') { finishNexusAdvisors(g, canceled); return; } if (response.kind === 'nexusSardaukar') { finishNexusSardaukar(g,canceled); return; } if (ecazCollectionQuote) { if (canceled) log( g, 'Karama prevented Ecaz’s co-occupied stronghold income. Its ally collects normally; shared desert spice is unaffected.', ); commitCollection(g, ecazCollectionQuote, canceled); return; } if (response.source === 'ambassador') { finishAmbassadorPurchaseResponse(g, response, canceled); return; } if (choamCancellation) { const { declaration, storm, revival } = choamCancellation; finishNexusChoam(g,g.pendingChoamWorthless!,'canceled'); g.pendingChoamWorthless = declaration.pendingChoamWorthless; g.choamWorthlessBlocked = declaration.blocked; log(g, 'CHOAM’s Worthless card effect was prevented for this phase. The declared Treachery card is retained. Any Nexus card spent on the declaration remains spent.'); if (storm) commitChoamStormOffer(g, storm); else if (revival) offerRevivalStop(g, revival); else if (declaration.resume.kind === 'movement') resumeChoamMovement(g); else if (declaration.resume.kind === 'mentat') g.decision = declaration.resume.decision; return; } if (auctionCancellation) { if (response.kind === 'harkonnenBonus') log(g, 'Karama prevented the Harkonnen bonus treachery card.'); commitAuctionContinuation(g, auctionCancellation); return; } if (response.kind === 'richeseGift') { const pending = g.pendingRicheseGift; requireRule( pending && pending.intent.owner === response.owner && pending.turn === g.turn && pending.phase === g.phase, 'This Richese gift response is no longer current.', ); let transfer: ReturnType | null = null; if (!canceled) { const context = richeseGiftContext(g); try { const candidate = validateRicheseGift( context, getPlayer(context, pending.intent.owner), pending.intent.cardId, ); requireRule( candidate.intent.owner === pending.intent.owner && candidate.intent.recipient === pending.intent.recipient && candidate.intent.cardId === pending.intent.cardId, 'The original gift recipient is no longer your current ally.', ); transfer = candidate; } catch (error) { if (!(error instanceof RuleError)) throw error; log( g, 'The Richese gift became unavailable before transfer. No card moved; the interrupted decision resumes.', ); } } if (canceled) { requireRule( richeseCancellation?.kind === 'richeseGift', 'Missing canceled Richese gift.', ); g.richeseGiftBlocked = richeseCancellation.blocked; log( g, 'Karama prevented this Richese gift. The card remains in its owner’s hand; the interrupted decision resumes.', ); } else if (transfer) { getPlayer(g, pending.intent.owner).hand = transfer.resulting.ownerHand; getPlayer(g, pending.intent.recipient).hand = transfer.resulting.recipientHand; log( g, `${getPlayer(g, pending.intent.owner).name} gave their ally one Richese Treachery Card. The exact card is private, and this gift creates no auction income, bonus draw or replacement.`, { faction: 'richese', name: 'Allied card gift' }, ); } const resume = richeseCancellation?.kind === 'richeseGift' ? richeseCancellation.resume : pending.resume; g.pendingRicheseGift = null; g.response = resume.response; g.decision = resume.decision; g.pendingKarama = resume.pendingKarama; g.phaseOpening = resume.phaseOpening; } else if (response.kind === 'richeseNoField') { const quote = canceled ? noFieldCancellationQuote(g, response) : null; const shipment = g.pendingShipment; requireRule( shipment && ((shipment.noField && shipment.player === response.owner) || shipment.alliedNoField?.owner === response.owner), 'No current No-Field shipment is awaiting this response.', ); if (canceled && shipment.richesePair) finishRichesePairShipment(g, shipment, 'stopped'); g.pendingShipment = null; if (canceled) { requireRule(quote, 'Missing canceled No-Field declaration.'); if (quote.kind === 'allied') g.richeseAllyBlocked = quote.blocked; else getPlayer(g, quote.player).noFieldBlockedTurn = quote.blockedTurn; log( g, `Karama prevented No-Field use for this shipment opportunity. No spice, reserves or token history changed; normal shipment remains available.${shipment.richesePair ? ' The Richese Nexus card remains spent.' : ''}`, ); } else offerShipment(g, shipment); } else if (response.kind === 'moritaniRetention') { const pending = g.moritaniRetention; requireRule( pending?.stage === 'response' && pending.owner === response.owner && pending.keep, 'No Moritani alliance retention is awaiting this response.', ); if (canceled) log(g, 'Karama prevented Moritani’s ally from retaining a battle card.'); finishMoritaniRetention(g, canceled ? null : pending.keep); } else if (response.kind === 'moritaniDuke') { requireRule( g.phase === 5 && g.dukeAcquisitionTurn === g.turn && g.dukeVidal, 'This Duke Vidal acquisition is no longer current.', ); if (!canceled) { g.dukeVidal = acquireDuke( g.dukeVidal, response.owner, g.turn, 'moritani', ); log( g, `${getPlayer(g, response.owner).name} acquired Duke Vidal for one battle this turn.`, ); } else log(g, 'Karama prevented Moritani from acquiring Duke Vidal this turn.'); nextPhase(g); } else if (response.kind === 'moritaniAlliance') { const entry = g.pendingTerrorEntry; requireRule( entry && entry.stage === 'allianceResponse' && entry.turn === g.turn && entry.phase === g.phase, 'This Enemy of My Enemy opportunity is no longer current.', ); if (canceled) { requireRule(allianceCancellation, 'Missing canceled Moritani alliance.'); g.pendingTerrorEntry = allianceCancellation.entry; g.decision = allianceCancellation.decision; } else { entry.stage = 'allianceReply'; g.decision = { kind: 'moritaniTerror', player: entry.entrant, entrant: entry.entrant, territory: entry.territory, }; } log( g, canceled ? 'Karama prevented Enemy of My Enemy; Moritani may still reveal the token.' : `${getPlayer(g, entry.entrant).name} may accept or refuse Moritani’s alliance offer.`, ); } else if (response.kind === 'ecazPlacement') { const pending = g.pendingEcazPlacement; requireRule( pending && pending.turn === g.turn && g.phase === 4 && getPlayer(g, response.owner).faction === 'ecaz' && g.ecazPlacementTurn !== g.turn, 'No Ambassador placement is pending for this turn.', ); const owner = getPlayer(g, response.owner); if (canceled) { requireRule( placementCancellation?.receipt.kind === 'ecazPlacement', 'Missing Ambassador denial receipt.', ); g.ecazAmbassadors = placementCancellation.receipt.ambassadors; log( g, `${owner.name}'s remaining Ambassador placements were canceled for this turn.`, ); finishEcazPlacement(g); } else { const quote = ambassadorPlacementQuote( g, owner, pending.token, pending.territory, ); requireRule( quote.cost === pending.cost, 'The Ambassador placement price changed.', ); owner.spice -= quote.cost; g.ecazAmbassadors = quote.state; g.pendingEcazPlacement = null; log( g, `${owner.name} placed the ${faction(quote.state.tokens.find((t) => t.id === pending.token)!.effect).name} Ambassador in ${territory(pending.territory).name} for ${quote.cost} spice. The next Ambassador placed this turn will cost ${quote.cost + 1} spice.`, { faction: owner.faction, name: 'Ambassador placement' }, ); g.decision = { kind: 'ecazPlacement', player: owner.id }; } } else if (response.kind === 'moritaniPlacement') { const pending = g.pendingMoritaniPlacement; requireRule( g.phase === 8 && pending?.turn === g.turn && g.moritaniTerror, 'The Terror placement opportunity is no longer current.', ); if (canceled) { requireRule( placementCancellation?.receipt.kind === 'moritaniPlacement', 'Missing Terror denial receipt.', ); finishNexusMoritani(g,true); g.moritaniTerror = placementCancellation.receipt.terror; log( g, 'Moritani’s Terror placement or relocation was prevented for this turn.', ); } else { try { const placed = pending.nexusEvent ? quoteNexusMoritaniPlacement(g.moritaniTerror,pending.token,pending.territory,g.turn) : placeTerror(g.moritaniTerror,pending.token,pending.territory,g.turn); finishNexusMoritani(g,false); g.moritaniTerror = placed; if (pending.nexusEvent) (g.nexusMoritaniLocations ??= {})[pending.token] = {event:pending.nexusEvent,territory:pending.territory}; else if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[pending.token]; } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Terror placement.', ); } log( g, `Moritani placed a hidden Terror token in ${territory(pending.territory).name}.`, ); } g.pendingMoritaniPlacement = null; if (placementCancellation?.finalVictory && !g.moritaniExtortion) victory(g, placementCancellation.finalVictory); else finishMoritaniPlacement(g); } else if (response.kind === 'choamWorthless') { const pending = g.pendingChoamWorthless!; g.pendingChoamWorthless = null; const choam = getPlayer(g, pending.owner); if ( choam.hand.some((c) => c.id === pending.card) && (pending.effect !== 'gamont' || gamontAvailable( g, pending.target!, pending.location!, pending.elite!, pending.noFieldEvent, )) && (pending.effect !== 'baliset' || at(choam, pending.location!) > 0) && (pending.effect !== 'jubba' || choamStormOptions(g).some((t) => t.territory === pending.location)) ) { const used = discard(g, choam, pending.card); finishNexusChoam(g,pending,'complete'); if (pending.effect === 'kulon') { if (g.semutaPreview && used.name === 'Kulon' && !pending.storm && !pending.revival && !pending.movement && !pending.mentat && !pending.nexusEvent) { const continuation: ChoamKulonDiscardContinuation = { kind: 'choamKulonDiscard', player: choam.id, card: used.id, before: g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0, stateSignature: '', }; const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamKulonDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:kulon', entries, continuation); return; } } completeChoamKulon(g, choam); } else if (pending.effect === 'jubba') { (g.stormResolution!.choamProtected ??= []).push(pending.location!); log( g, `${choam.name} protected its own forces in ${territory(pending.location!).name} for this storm movement with Jubba Cloak.`, ); } else if (pending.effect === 'baliset') { if (g.semutaPreview && used.name === 'Baliset' && !pending.storm && !pending.revival && !pending.movement && !pending.mentat && !pending.nexusEvent) { const continuation: ChoamBalisetDiscardContinuation = { kind: 'choamBalisetDiscard', player: choam.id, card: used.id, target: pending.target!, territory: pending.location!, stateSignature: '', }; const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamBalisetDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:baliset', entries, continuation); return; } } completeChoamBaliset(g, pending.target!, pending.location!); if (pending.movement) { const ambassador = g.pendingChoamMove?.source === 'ambassador'; if (ambassador) currentFremenAmbassador(g, g.pendingChoamMove!.ambassadorEvent); g.pendingChoamMove = null; log( g, 'The declared move was prevented without moving forces or spending a movement.', ); if (ambassador) offerAmbassadorRelocation(g); } } else if (pending.effect === 'gamont') { if (g.semutaPreview && used.name === 'Trip to Gamont' && pending.elite === 0 && !pending.noFieldEvent && !pending.storm && !pending.revival && !pending.movement && !pending.mentat && !pending.nexusEvent) { const continuation: ChoamGamontDiscardContinuation = { kind: 'choamGamontDiscard', player: choam.id, card: used.id, target: pending.target!, location: pending.location!, stateSignature: '', }; const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamGamontDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:gamont', entries, continuation); return; } } completeChoamGamont(g, choam, pending.target!, pending.location!, pending.elite!, pending.noFieldEvent); } else { if (g.semutaPreview && used.name === 'La La La' && !pending.storm && !pending.revival && !pending.movement && !pending.mentat && !pending.nexusEvent) { const continuation: ChoamLaLaLaDiscardContinuation = { kind: 'choamLaLaLaDiscard', player: choam.id, card: used.id, target: pending.target!, stateSignature: '', }; const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamLaLaLaDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choam:laLaLa', entries, continuation); return; } } completeChoamLaLaLa(g, pending.target!); if (pending.revival) { g.pendingRevival = null; log( g, 'The free revival request was stopped without moving forces or paying spice. Choose a paid revival if desired.', ); } } } else { finishNexusChoam(g,pending,'fizzled'); log( g, 'The declared card or force is no longer available; no Worthless effect occurs.', ); } if (pending.storm && g.stormResolution) offerChoamStorm(g); if (pending.revival && g.pendingRevival) offerRevivalStop(g); if (pending.movement && g.pendingChoamMove) resumeChoamMovement(g); if (pending.mentat) g.decision = { kind: 'choamMentat', player: choam.id }; } else if (response.kind === 'richeseAuction') { requireRule( !canceled, 'The canceled Richese auction count is awaiting an official ruling or an explicit table interpretation.', ); if (g.richeseBidding!.position === 'first') offerRicheseCache(g); else prepareRicheseNormal(g); } else if (response.kind === 'richeseBlackMarket') { if (canceled) { g.richeseAuction!.outcome = { kind: 'unbid' }; settleRicheseLot(g); } else { const atreides = byFaction(g, 'atreides'); if (atreides) g.response = { kind: 'atreidesAuction', owner: atreides.id, passed: [], }; else settleRicheseLot(g); } } else if (response.kind === 'ixAuction') { const request = currentIxAuctionDrawQuote(g, response, canceled)!; const pending = g.ixAuction!; const cards: Card[] = []; for (let i = 0; i < request.drawCount; i++) { const card = draw(g); if (card) cards.push(card); } if (!canceled && cards.length > 1) { pending.cards = cards; g.decision = { kind: 'ixAuction', player: response.owner }; } else { g.ixAuction = null; setAuction(g, cards); } } else if (response.kind === 'ixTechnology') { if (technologyCancellation) { g.pendingIxTechnology = technologyCancellation.pendingIxTechnology; g.auction!.peekKnown = technologyCancellation.peek.peekKnown; g.response = technologyCancellation.peek.response; log(g, 'Ixian auction substitution did not occur.'); return; } const pending = g.pendingIxTechnology!; const ixians = getPlayer(g, response.owner); const replacement = ixians.hand.find((c) => c.id === pending.card); if (!canceled && replacement) { const a = g.auction!, taken = a.cards[a.index]; ixians.hand = ixians.hand.filter((c) => c.id !== replacement.id); ixians.hand.push(taken); a.cards[a.index] = replacement; if (g.ixAuctionKnown?.turn === g.turn) g.ixAuctionKnown.cards = [ ...g.ixAuctionKnown.cards.filter( (c) => c.id !== taken.id && c.id !== replacement.id, ), replacement, ]; log( g, `${ixians.name} exchanged a hand card for the upcoming auction card.`, ); } else log(g, 'Ixian auction substitution did not occur.'); g.pendingIxTechnology = null; offerAuctionPeek(g); } else if (response.kind === 'ixAllyCard') { const pending = g.pendingIxAlly!; const buyer = getPlayer(g, pending.player); if (!canceled && buyer.hand.some((c) => c.id === pending.card)) { const sale = g.currentAuctionSale; requireRule( sale, 'The paid auction must remain available for its replacement.', ); const card = discard(g, buyer, pending.card); g.pendingIxAlly = null; stageTreacheryDiscard( g, 'ixAllyCard', [ { card, discardedBy: buyer.id, publicFace: sale.origin === 'cache', }, ], { kind: 'ixAllyCard', ...pending, sale, auctionIndex: g.auction?.index ?? null, auctionEvent: g.richeseAuction?.event ?? null, }, ); return; } g.pendingIxAlly = null; continueAuctionSale(g, pending.free); } else if (response.kind === 'mobileStronghold') { if (canceled) movementCancellationQuote(g, response); const pending = g.pendingMobileMove!; g.pendingMobileMove = null; if (!canceled) relocateMobileStronghold(g, pending); else log(g, 'Mobile stronghold movement and spice collection were canceled.'); beginStormTurn(g); } else if (response.kind === 'fremenMovement') { const pending = pendingFremenMovement(g, response); const quote = canceled ? movementCancellationQuote(g, response) : null; const player = getPlayer(g, pending.order.player); g.pendingFremenMove = null; if (canceled) { requireRule( quote?.kind === 'fremenMovement', 'Missing canceled Fremen movement.', ); player.fremenMovementBlocked = { turn: quote.turn, move: quote.move }; log( g, `${player.name}'s two-territory movement advantage was canceled for this move. The forces remain in place and no movement was spent. Choose a legal replacement; independent ornithopters and Planetologist still apply.`, ); } else { log( g, `${player.name}'s two-territory movement advantage was allowed. The declared movement continues.`, { faction: 'fremen', name: 'Fremen movement' }, ); offerChoamMovement(g, pending.order); } } else if (response.kind === 'ixMovement') { const quote = canceled ? movementCancellationQuote(g, response) : null; const move = g.pendingIxMove!; // Cancellation only releases the declaration; its uncommitted army or route // need not remain executable. An allowed move must still be legal now. if (!canceled) validateMovementOrder(g, getPlayer(g, move.player), move); g.pendingIxMove = null; if (canceled) { const player = getPlayer(g, move.player); requireRule( quote?.kind === 'ixMovement', 'Missing canceled Ixian movement.', ); player.ixMovementBlocked = { turn: quote.turn, move: quote.move }; log( g, `${player.name}'s cyborg movement advantage was canceled for this move. Forces remain in place and no movement was spent. Independent ornithopters and Planetologist still apply to a legal replacement.`, ); } else offerChoamMovement(g, move); } else if (response.kind === 'ixSubstitution') { ixSubstitutionCancellationQuote(g, response); const pending = g.pendingIxSubstitution!; const player = getPlayer(g, pending.player); if (!canceled && pending.homeworld) { homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); const amount = Object.values(pending.sources!).reduce((a, b) => a + b, 0); const quote = quoteHomeworldSubstitution(homeworldLossContext(g), g.homeworlds!.custody!, { location: pending.territory, player: player.id, amount, cyborgsLost: pending.homeworld.cyborgsLost, }); commitHomeworldResources(g, quote); log(g, `${player.name} exchanged ${amount} surviving Suboids for the same number of Cyborgs lost at ${combatLocationName(g, pending.territory)}.`); } else if (!canceled) { for (const [key, count] of Object.entries(pending.sources!)) kill(g, player, key, count, true, 0); for (const [key, count] of Object.entries(pending.recover!)) { player.tanks -= count; player.elites!.tanks -= count; player.battleLosses -= count; const loc = splitLocation(key); place(player, loc.territory, loc.sector, count, count); } log( g, `${player.name} substituted surviving suboids for cyborgs lost in this battle.`, ); } observeOccupation(g); completeIxSubstitution(g); if (canceled) { log( g, 'Karama prevented Ixian substitution. Original cyborg casualties remain in the Tanks; no surviving suboids were exchanged.', ); } finishWinner(g, player, pending.territory, pending.cards); } else if ( [ 'choamRevival', 'revivalLimit', 'revivalDiscount', 'earlyRevival', 'foreignGhola', ].includes(response.kind) ) { if (canceled) { const quote = revivalCancellationQuote(g, response)!; const player = getPlayer(g, g.pendingRevival!.player); g.revivalRules = quote.rules; g.pendingRevival = quote.pending; if (quote.block?.kind === 'foreignGhola') { player.gholaBlocked ??= {}; player.gholaBlocked[quote.block.leader] = g.turn; } if (response.kind === 'choamRevival') { log(g, 'CHOAM revival advantage was canceled for this phase.'); if (quote.outcome === 'abandoned') log( g, `${player.name} must choose a revival within the remaining allowance. No forces or spice changed.`, ); } else log(g, `${player.name}'s revival benefit was canceled.`); } else if (response.kind !== 'choamRevival') g.revivalRules ??= newRevivalRules(); if (g.pendingRevival) finishRevival(g); } else if (response.kind === 'revivalIncome') { if (!canceled) getPlayer(g, response.owner).spice += response.amount! - (bureaucratDiversion ?? 0); if (g.pendingChoamMarketGhola && g.pendingChoamMarketGhola.event === response.intent) g.pendingChoamMarketGhola.stage = 'complete'; } else if (response.kind === 'faceDancerReplacement') { const owner = getPlayer(g, response.owner); const index = owner.faceDancers!.findIndex( (c) => c.leader === response.intent && !c.revealed, ); if (!canceled && index >= 0) { g.traitorReserve = shuffle([ ...(g.traitorReserve ?? []), owner.faceDancers![index].leader, ]); owner.faceDancers![index] = { leader: g.traitorReserve.shift()!, revealed: false, }; log(g, `${owner.name} replaced one unrevealed Face Dancer.`); } } else if (response.kind === 'worthlessKarama') { const pending = g.pendingKarama!; g.pendingKarama = null; const owner = getPlayer(g, pending.owner); if (!canceled) completeKarama(g, owner, pending.use); else if (pending.use.kind === 'cancel') g.response = pending.use.response; else if (pending.use.kind === 'auctionPayment') recoverAuctionPayment(g, owner); } else if (response.kind === 'choamSale') { const market = g.choamMarket!; const owner = getPlayer(g, market.owner); const sale = market.sale!; if (canceled) { const quote = choamSaleCancellationQuote(g, response)!; g.choamMarket = quote.market; resumeChoamMarket(g); log( g, 'The declared CHOAM card sale was canceled; the card stays in hand.', ); return; } const saleCard = owner.hand.find((card) => card.id === sale.card); const saleBlock = saleCard ? homeworldRule(() => homeworldWorthlessSaleBlock(g, owner.id, saleCard)) : null; const quoted = quoteSale(owner.hand, sale.card, sale.witness); if (quoted?.price === sale.price && !saleBlock) { const used = discard(g, owner, sale.card); owner.spice += sale.price; log(g, `${owner.name} received ${sale.price} spice from the card sale.`); delete market.sale; if (g.semutaPreview) { const continuation: ChoamSaleDiscardContinuation = { kind: 'choamSaleDiscard', owner: owner.id, card: used.id, witness: sale.witness ?? null, price: sale.price, spiceAfter: owner.spice, stateSignature: '', }; const entries = [{ card: used, discardedBy: owner.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = choamSaleDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'choamSale', entries, continuation); return; } } } else { log( g, saleBlock ? 'Tupile is now high population, so the declared Worthless sale cannot finish. The card stays in hand and no spice is paid.' : 'The declared card sale is no longer possible; no spice was paid.', ); delete market.sale; } resumeChoamMarket(g); } else if (response.kind === 'choamInflation') { if (!canceled) { g.inflation = { side: response.intent as Inflation['side'], placedTurn: g.turn, updatedTurn: g.turn, flipped: false, }; g.inflationUsed = true; log( g, `CHOAM placed Inflation: ${g.inflation.side} next turn’s charity.`, ); } else log(g, 'Inflation placement was canceled for this Mentat Pause.'); } else if (response.kind === 'choamCharity') { g.choamCharity = terminal?.kind === 'choamCharity' ? terminal.receipt : { turn: g.turn, canceled }; if (!canceled) { const choam = getPlayer(g, response.owner); requireRule( homeworldLowBonus(g, choam.id) === 0, 'Low-population CHOAM opening income awaits its Homeworld charity ruling.', ); const amount = 2 * g.players.length * charityMultiplier(g); choam.spice += amount; log(g, `${choam.name} collected ${amount} spice before charity claims.`); } else log( g, 'CHOAM income was canceled. Charity is paid by the Spice Bank this turn.', ); } else if (response.kind === 'bgCharity') { if (!canceled) payCharity( g, getPlayer(g, response.owner), response.amount ?? 2, response.charityHomeworld ?? 0, ); } else if (response.kind === 'advisorFlip') { if (canceled) movementCancellationQuote(g, response); const bg = getPlayer(g, response.owner), to = response.location!; if (!canceled) { bg.advisors ??= {}; if (response.advisors) bg.advisors[to] = {}; else delete bg.advisors[to]; log( g, `${bg.name} changed all forces in ${territory(to).name} to ${response.advisors ? 'advisors' : 'fighters'}.`, ); } finishAdvisorReaction(g, response); } else if (response.kind === 'capture') { const capture = g.pendingCapture!; g.pendingCapture = null; if (canceled) finishBattle(g); else { const candidates = captureCandidates(g, capture.loser, capture.territory); const l = shuffle(candidates)[0]; if (!l) { finishBattle(g); return; } const owner = g.players.find((p) => p.leaders.includes(l))!; l.concealed = { captor: capture.player, controller: capture.loser, dead: l.dead, deaths: l.deaths, usedAt: l.usedAt, }; l.capturedBy = capture.player; const capturedSkill = g.leaderSkills?.assignments.find((a) => a.leader === l.id); // This unique card and its leader were publicly assigned. The expansion // requires the card to accompany the captive, identifying that capture. if (capturedSkill) delete l.concealed; g.decision = { kind: 'capturedLeader', player: capture.player, owner: owner.id, leader: l.id, controller: capture.loser, }; log( g, capturedSkill ? `${getPlayer(g, capture.player).name} captured ${l.name} with ${leaderSkillCard(capturedSkill.skill).name}. Its known skill travels with the leader; only its battle effect is available to the captor.` : `${getPlayer(g, capture.player).name} captured a leader from ${getPlayer(g, capture.loser).name}.`, ); } } else if (response.kind === 'choamAudit') { const pending = g.pendingAuditor; requireRule( pending && pending.stage === 'response' && response.intent === pending.event && response.owner === pending.owner, 'This Auditor cancellation window is stale.', ); if (canceled) finishAuditor(g, 'cancel'); else if ( !currentAuditCount(g) || getPlayer(g, pending.opponent).spice < currentAuditCount(g) ) finishAuditor(g, 'inspect'); else { pending.stage = 'payment'; g.decision = { kind: 'choamAuditPayment', player: pending.opponent, event: pending.event, }; } } else if (response.kind === 'choamBattleIncome') { const pending = g.pendingChoamBattleIncome!; g.pendingChoamBattleIncome = null; if (!canceled) { const choam = getPlayer(g, pending.owner); choam.spice += pending.amount; log( g, `${choam.name} received ${pending.amount} spice from battle force payments.`, ); } else log(g, 'CHOAM battle force payments went to the bank.'); finishBattle(g); } else if (response.kind === 'nexusPrescience') { settleNexusInspectionResponse(g, response, canceled); } else if (isCombatResponseKind(response.kind)) { commitCombatResponseQuote( g, currentCombatResponseQuote(g, { kind: 'response', response: { kind: response.kind, owner: response.owner }, canceled, }), ); } else if (response.kind === 'guildTiming') { if (canceled) { const quote = movementCancellationQuote(g, response); requireRule( quote?.kind === 'guildTiming', 'Missing canceled Guild timing.', ); g.guildTimingLocked = true; g.active = quote.active; } else { g.guildTimingGranted = true; chooseGuildTiming(g, response.take!); } } else if (response.kind === 'atreidesAuction') { if (g.richeseAuction) { g.richesePeekKnown = !canceled; settleRicheseLot(g); } else g.auction!.peekKnown = terminal?.kind === 'atreidesAuction' ? terminal.known : !canceled; } else if (response.kind === 'atreidesSpice') { const blocked = homeworldRule(() => homeworldMovementForesightBlock(g, response.owner)); requireRule(!blocked, blocked ?? 'Caladan prevents this foresight.'); g.spicePeekKnown = terminal?.kind === 'atreidesSpice' ? terminal.known : !canceled; } else if (response.kind === 'wormPlacement') { if (canceled) { g.wormPlacementCanceledTurn = g.turn; continueSpice(g); } else beginWorm(g, response.location!, 'additional'); } else if (response.kind === 'wormAllyProtection') { wormSurvival( g, response.location!, canceled ? undefined : response.recipient, ); } else if (response.kind === 'wormSurvival') { devour(g, response.location!, response.recipient, !canceled); afterWorm(g); } else if (response.kind === 'stormProtection') { if (response.resume === 'storm') { g.stormResolution!.protected = !canceled; continueStorm(g); } else { const p = getPlayer(g, response.owner); if (canceled) kill( g, p, response.location!, response.amount!, false, response.elite ?? 0, ); else stormCasualties( g, p, response.location!, response.amount!, response.elite ?? 0, 'shipment', ); } } else if (response.kind === 'stormPeek') { g.stormCardKnown = terminal?.kind === 'stormPeek' ? terminal.known : !canceled; } else if ( response.kind === 'emperorGift' || response.kind === 'emperorRevival' ) { if (!canceled) { const emperor = getPlayer(g, response.owner), recipient = getPlayer(g, response.recipient!); const amount = response.amount!; if (response.kind === 'emperorGift') { emperor.spice -= amount; recipient.spice += amount; } else { beginRevival(g, { player: recipient.id, payer: emperor.id, emperorExtra: true, kind: 'forces', amount, elite: response.elite ?? 0, cost: paidForceRevivalCost(recipient, amount, response.elite ?? 0), normalCost: paidForceRevivalCost( recipient, amount, response.elite ?? 0, ), free: 0, checks: [], }); } log( g, `${emperor.name} ${response.kind === 'emperorGift' ? 'shared spice with' : `offered ${amount} extra force revivals to`} ${recipient.name}.`, ); } } else if (response.kind === 'guildIncome') { const payment = !canceled ? creditFactionPayment(g, response.owner, 'shipment', response.amount!-(bureaucratDiversion ?? 0)) : null; log( g, canceled ? `Karama sent the ${response.amount} spice of Guild shipment income to the bank. The shipment and its price remain unchanged.` : `${getPlayer(g, response.owner).name} collected ${payment!.income} spice paid by other factions for shipment.${payment!.bank ? ` Low-population Junction leaves ${payment!.bank} spice in the bank.` : ''} Spice contributed by the Guild itself goes to the bank.`, { faction: 'guild', name: canceled ? 'Shipment income prevented' : 'Shipment income', }, ); } else if (response.kind === 'harkonnenTraitor') { const b = g.battle!; if (canceled) b.traitorCalls[response.owner] = false; if ( traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined) ) advanceBattleOutcome(g); } else if (response.kind === 'richesePurchaseIncome') { const pending = g.pendingRichesePurchaseIncome; requireRule( pending && pending.owner === response.owner && pending.turn === g.turn && pending.phase === g.phase && response.amount === 3, 'This Richese purchase income is no longer current.', ); const payment = !canceled ? creditFactionPayment(g, response.owner, 'treachery', 3) : null; log( g, canceled ? 'Karama prevented the Emperor from collecting the 3-spice Richese purchase payment. The purchase remains completed and the payment goes to the bank.' : `${getPlayer(g, response.owner).name} collected ${payment!.income} spice from another faction’s Treachery Card purchase payment.${payment!.bank ? ` Low-population Kaitain leaves ${payment!.bank} spice in the bank.` : ''}`, { faction: 'emperor', name: canceled ? 'Purchase income prevented' : 'Card purchase income', }, ); const resume = richeseCancellation?.kind === 'richesePurchaseIncome' ? richeseCancellation.resume : pending.resume; g.pendingRichesePurchaseIncome = null; g.response = resume.response; g.decision = resume.decision; g.pendingKarama = resume.pendingKarama; } else if (response.kind === 'emperorIncome') { if (!canceled) { const owner = getPlayer(g, response.owner); const amount = g.currentAuctionSale?.amount ?? g.auction!.bid; const payment = creditFactionPayment(g, owner.id, 'treachery', amount-(bureaucratDiversion ?? 0)); log( g, `${owner.name} received ${payment.income} spice from another faction’s ${amount}-spice paid auction bid.${payment.bank ? ` Low-population Kaitain leaves ${payment.bank} spice in the bank.` : ''}`, { faction: owner.faction, name: 'Auction income' }, ); } auctionBonus(g); } else if (response.kind === 'harkonnenBonus') { if (!canceled) { const owner = getPlayer(g, response.owner); const room = owner.hand.length < handLimit(owner); const bonus = room ? draw(g) : undefined; if (bonus) { owner.hand.push(bonus); log( g, `${owner.name} drew one bonus treachery card because Harkonnen receives an extra card after winning an auction while below its eight-card limit. Its identity remains private.`, { faction: owner.faction, name: 'Bonus treachery card' }, ); } else log( g, room ? `${owner.name} received no Harkonnen bonus card because no treachery card was available to draw.` : `${owner.name} received no Harkonnen bonus because their hand reached its eight-card limit before the draw.`, ); } else log(g, 'Karama prevented the Harkonnen bonus treachery card.'); nextAuction(g); } else if (response.kind === 'advisor') { if (response.advisorResume === 'ambassador') { finishGuildAmbassadorAdvisor(g, response, canceled); return; } if (canceled) return; const owner = getPlayer(g, response.owner); const target = splitLocation(response.location ?? 'polar_sink:0'); const amount = integer(response.amount === undefined ? 1 : response.amount, 1, spiritualAdvisorMaximum(g, owner.id, target.territory), 'Spiritual Advisor forces'); const advisors = arrivalAsAdvisor( g, owner, target.territory, undefined, target.territory !== 'polar_sink', ); allowedEntry(g, owner, target.territory, target.sector, false, advisors); withdrawNativeReserves(g, owner, amount, 0); place(owner, target.territory, target.sector, amount); techIncome(g, 'heighliners', owner); if (advisors) { owner.advisors ??= {}; owner.advisors[target.territory] = { lockedTurn: g.turn }; } log( g, `${owner.name} sent ${amount} free ${amount === 1 ? 'force' : 'forces'} to ${territory(target.territory).name}${amount === 2 ? ' using high-population Wallach IX' : ''}.`, {faction: owner.faction, name: 'Spiritual Advisors'}, ); openTerritoryEntry( g, owner, target.territory, target.sector, amount, 0, 'advisor', ); } else throw new RuleError('Unknown faction response.'); } function homeworldAllianceReason(g: Game, first: string, second: string) { return g.homeworlds?.custody ? homeworldRule(() => homeworldAllianceBlock(homeworldContext(g), g.homeworlds!.custody!, first, second)) : null; } function homeworldShipmentAutomatic(g: Game): boolean { const decision = g.decision; if (decision?.kind !== 'homeworldShipmentGuild' || g.response || g.truthtrance || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingKarama || g.pendingExchange || g.pendingRicheseGift || g.summonedWorm) return false; homeworldShipmentIntegrity(g); const guild = getPlayer(g, decision.player); return !guild.hand.some((card) => { if (!canUseAsKaramaRole(g, guild, card)) return false; try { prepareSpecialKaramaIntent(g, guild.id, {type: 'card', mode: 'special', card: card.id}); return true; } catch (error) { if (error instanceof RuleError) return false; throw error; } }); } function homeworldShipmentEvent(g: Game, p: Player, route?: 'arrakis'): string { if (route === 'arrakis') return JSON.stringify([homeworldShipmentEvent(g, p), route, g.storm, p.forces, p.elites?.forces ?? {}, g.mobileStronghold?.location ?? null]); return JSON.stringify([g.turn, g.phase, g.active, p.id, p.shipped, homeworldContext(g), g.homeworlds?.custody, g.players.map((seat) => [seat.id, seat.ally]), ...(currentGuildCunning(g,p.id) ? [currentGuildCunning(g,p.id)!.receipt.event] : [])]); } function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): string | null { if (!g.homeworlds?.custody) return 'Homeworld shipment requires the Homeworld module.'; if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || !shipmentAvailable(g,p)) return 'Use your unused shipment during your own Shipment and Movement turn.'; if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingShipment || g.pendingHomeworldShipment || g.pendingExchange || g.pendingRicheseGift || g.battle) return 'Finish the current interaction before another Homeworld shipment.'; if (g.karamaShipping?.player === p.id) return 'Homeworld shipment using a purchased Karama rate awaits its card-scope integration.'; const richese = byFaction(g, 'richese'); if (richese && !noFieldAllyOfferBlock(g, richese) && !(g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === p.id)) return 'Wait for Richese to offer or pass its allied shipment opportunity.'; try { if (checkPromises) checkShipmentPromises(g, p, null); } catch (error) { if (error instanceof RuleError) return error.message; throw error; } return null; } function guildHomeworldShipmentBlock(g: Game, p: Player): string | null { if (p.faction !== 'guild') return 'Only Guild has this Arrakis-to-Homeworld shipment permission.'; return homeworldShipmentBlock(g, p); } function junctionTransportWindow(g: Game) { const owner = homeworldRule(() => junctionSponsor(g)); const recipient = g.players.find((seat) => seat.id === g.active); if (!owner || !recipient || owner === recipient.id || g.phase !== 5 || g.status !== 'playing' || recipient.shipped) return null; const offer = currentJunctionOffer(g); const blocked = homeworldShipmentBlock(g, recipient, false); return {owner, recipient: recipient.id, offer, blocked, canOffer: !blocked, offerEvent: junctionOfferEvent(g), event: JSON.stringify([homeworldShipmentEvent(g, recipient, 'arrakis'), offer])}; } function performJunctionTransport(g: Game, p: Player, action: Action) { const option = junctionTransportWindow(g); requireRule(option && option.recipient === p.id && !option.blocked && option.offer, option?.blocked ?? 'Wait for Guild to offer Junction transport during your unused shipment.'); requireRule(action.event === option.event && action.offer === option.offer.event, 'This Junction offer or physical source selection has changed.'); requireRule(Object.keys(action).every((key) => ['type', 'event', 'offer', 'destination', 'sources', 'allyPayment'].includes(key)), 'Use the offered tariff and explicit physical forces without concealed tokens.'); const destination = stringField(action.destination); const context = {...homeworldContext(g), storm: g.storm, discoveries:g.discoveries, players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})), mobileStronghold: g.mobileStronghold?.location ?? null, board: {[p.id]: {forces: p.forces, eliteForces: p.elites?.forces ?? {}, advisors: p.advisors}}}; const quote = homeworldRule(() => quoteJunctionTransport(context, g.homeworlds!.custody!, {player: p.id, sponsor: option.owner, rate: option.offer!.rate, destination, sources: action.sources as HomeworldShipmentIntent['sources']})); const arrival = quote.destinationKind === 'arrakis' ? splitLocation(destination) : null; const nativeDeparture = quote.originKind === 'homeworld' && quote.sources.every((source) => source.key === `homeworld:${p.faction}` || (p.faction === 'emperor' && source.key === 'homeworld:emperor:salusa')); const promise = nativeDeparture && arrival ? {territory: arrival.territory, amount: quote.amount} : null; checkShipmentPromises(g, p, promise); const sourceLock = quote.originKind === 'arrakis' ? p.advisors?.[quote.origin]?.lockedTurn : undefined; const advisors = arrival ? arrivalAsAdvisor(g, p, arrival.territory, quote.originKind === 'arrakis' ? quote.origin : undefined) : false; if (arrival) { requireRule(arrival.territory !== MOBILE_STRONGHOLD || p.faction === 'ixians', 'Only Ixians may ship directly into the mobile stronghold.'); requireRule(advisors || sourceLock !== g.turn || !g.players.some((other) => other.id !== p.id && at(other, arrival.territory)), 'New advisors cannot become fighters this turn.'); allowedEntry(g, p, arrival.territory, arrival.sector, false, advisors); } const allyPayment = contribution(g, p, quote.cost, action.allyPayment); // Junction permission and tariff are Homeworld effects and resist Karama. // All validation precedes this atomic settlement; there is no special-stop frame. payWithAlly(g, p, quote.cost, allyPayment); g.homeworlds!.custody = quote.state; p.forces = quote.boardForces; if (p.elites) p.elites.forces = quote.boardEliteForces; for (const seat of quote.players) { const owner = getPlayer(g, seat.id); owner.reserves = seat.reserves; if (owner.elites) owner.elites.reserves = seat.eliteReserves; } observeOccupation(g); if (arrival) { place(p, arrival.territory, arrival.sector, quote.amount, quote.elite); if (advisors) { const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0); (p.advisors ??= {})[arrival.territory] = lockedTurn ? { lockedTurn } : {}; } else if (p.advisors) delete p.advisors[arrival.territory]; } p.shipped = true; g.junctionOffer = null; finishShipmentPromises(g, p, promise); // Native Fremen reinforcement is on Arrakis; all other world departures // are off-planet. Homeworld destinations independently trigger E3 technology. const offPlanet = quote.originKind === 'homeworld' && !(nativeDeparture && p.faction === 'fremen'); if (!arrival || offPlanet) techIncome(g, 'heighliners', p); const ordinaryIncome = !!arrival && nativeDeparture && p.faction !== 'fremen'; const income = (ordinaryIncome ? quote.cost - allyPayment : 0) + (p.ally === option.owner ? 0 : allyPayment); if (income > 0) g.response = guildPaymentResponse(g, option.owner, [ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment]); const sourceName = quote.originKind === 'arrakis' ? territory(quote.origin).name : quote.sources.map((s) => combatLocationName(g, s.key)).join(' and '); log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${option.offer.rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.`, {faction: 'guild', name: 'Junction transport'}); if (arrival) { const bg = byFaction(g, 'beneGesserit'); const followup = offPlanet && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0 ? {shipment: p.id, destination} : undefined; if (!intrusion(g, p, arrival.territory, followup ? { followup } : undefined) && followup) g.decision = {kind: 'advisor', player: bg!.id, ...followup}; openTerritoryEntry(g, p, arrival.territory, arrival.sector, quote.amount, quote.elite, 'shipment'); } } function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) { requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.'); const context = { ...homeworldContext(g), players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})), }; const order = {player: intent.player, destination: intent.destination, sources: intent.sources}; if (intent.route === 'arrakis') { const quote = homeworldRule(() => quoteGuildHomeworldShipment({...context, storm: g.storm, discoveries:g.discoveries, mobileStronghold: g.mobileStronghold?.location ?? null, board: Object.fromEntries(g.players.map((p) => [p.id, {forces: p.forces, eliteForces: p.elites?.forces ?? {}}])), }, g.homeworlds!.custody!, order)); return {...quote, sources: quote.boardSources, sourceNames: [territory(quote.origin).name]}; } const quote = homeworldRule(() => quoteHomeworldShipment(context, g.homeworlds!.custody!, order)); if (intent.guildSecretEvent) { validateGuildSecretDeclaration(g,intent.player,intent.guildSecretEvent); quote.cost = nexusRule(() => quoteNexusGuildSecretShipment('homeworld',quote.amount)).cost; } return {...quote, boardForces: undefined, boardEliteForces: undefined, sourceNames: quote.sources.map((s) => combatLocationName(g, s.homeworld))}; } function validateHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) { if (shipment.guildSecretEvent) requireRule(!shipment.route && !shipment.guildNexusEvent, 'Guild Secret Ally discounts independently legal world-to-world routes only.'); if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'homeworld',shipment); const p = getPlayer(g, shipment.player); requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g,p) && shipment.turn === g.turn && (shipment.route === undefined || shipment.route === 'arrakis') && shipment.event === homeworldShipmentEvent(g, p, shipment.route), 'This Homeworld declaration no longer matches its unused shipment and physical custody.'); requireRule(g.karamaShipping?.player !== p.id, 'Homeworld shipment using a purchased Karama rate awaits its card-scope integration.'); const quote = homeworldShipmentQuote(g, shipment); requireRule(shipment.amount === quote.amount && shipment.elite === quote.elite && shipment.cost === quote.cost && JSON.stringify(shipment.pools) === JSON.stringify(quote.sources), 'The saved Homeworld shipment price or typed source pools changed.'); integer(shipment.allyPayment, 0, quote.cost, 'Shipment ally payment'); contribution(g, p, quote.cost, shipment.allyPayment); checkShipmentPromises(g, p, null); return quote; } function homeworldShipmentIntegrity(g: Game) { homeworldMobilityIntegrity(g); homeworldRule(() => junctionOfferIntegrity(g)); const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldShipmentGuild'); const shipment = g.pendingHomeworldShipment; if (!shipment && !decisions.length) return; requireRule(shipment && decisions.length > 0 && !g.pendingShipment, 'The saved Homeworld shipment needs its original Guild decision.'); validateHomeworldShipment(g, shipment); const guild = byFaction(g, 'guild'); requireRule(g.advanced && guild && !guild.specialKaramaUsed, 'This Homeworld shipment has no available Guild interception.'); for (const d of decisions) requireRule(d.player === guild.id && d.shipper === shipment.player && d.destination === shipment.destination && d.amount === shipment.amount && d.event === shipment.event, 'The saved Guild decision differs from its Homeworld declaration.'); } function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) { const quote = validateHomeworldShipment(g, shipment); if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'homeworld',shipment); if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'homeworld',shipment); const p = getPlayer(g, shipment.player); payWithAlly(g, p, quote.cost, shipment.allyPayment); g.homeworlds!.custody = quote.state; if (shipment.route === 'arrakis') { p.forces = quote.boardForces!; if (p.elites) p.elites.forces = quote.boardEliteForces!; } for (const seat of quote.players) { const owner = getPlayer(g, seat.id); owner.reserves = seat.reserves; if (owner.elites) owner.elites.reserves = seat.eliteReserves; } observeOccupation(g); p.shipped = true; g.pendingHomeworldShipment = null; // The E3 FAQ explicitly includes Homeworld shipment, including Fremen. techIncome(g, 'heighliners', p); // Off-planet destinations do not create the Guild's ordinary onto-Dune income. // November FAQ contributor routing remains independent of that trigger. const guild = byFaction(g, 'guild'); const income = guild && p.ally !== guild.id ? shipment.allyPayment : 0; if (guild && income > 0) g.response = guildPaymentResponse(g, guild.id, [income]); finishShipmentPromises(g, p, null); log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`, {faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'}); } function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}, allyPayment?: unknown) { const quote = homeworldShipmentQuote(g, intent); const shipment: PendingHomeworldShipment = {...intent, ...(currentGuildCunning(g,p.id) ? {guildNexusEvent:currentGuildCunning(g,p.id)!.receipt.event} : {}), sources: structuredClone(intent.sources), event: homeworldShipmentEvent(g, p, intent.route), turn: g.turn, amount: quote.amount, elite: quote.elite, cost: quote.cost, allyPayment: contribution(g, p, quote.cost, allyPayment), pools: quote.sources}; bindGuildCunningShipment(g,p,'homeworld',shipment); const guild = byFaction(g, 'guild'); if (g.advanced && guild && !guild.specialKaramaUsed) { g.pendingHomeworldShipment = shipment; g.decision = {kind: 'homeworldShipmentGuild', player: guild.id, shipper: p.id, destination: intent.destination, amount: quote.amount, event: shipment.event}; log(g, `${p.name} declared ${quote.amount} physical forces for ${combatLocationName(g, intent.destination)}. Payment and departure await the Guild interception decision.`); } else commitHomeworldShipment(g, shipment); } function noFieldSkillProof(shipment: PendingShipment): string { return JSON.stringify({ ...shipment, noFieldSkillProof: undefined }); } function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment) { requireRule(g.leaderSkills && smugglerNoFieldModeSupported(g) && shipment.noField && !shipment.alliedNoField && !shipment.smuggler && !shipment.source && !shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent && shipment.homeworldSources === undefined && shipment.ambassadorEvent === undefined, 'Smuggler No-Field shipping requires an own Richese marker without other shipment routes or modules.'); requireRule(typeof shipment.noFieldSkillProof === 'string' && shipment.noFieldSkillProof === noFieldSkillProof(shipment), 'The saved No-Field shipment changed its original marker or optional Smuggler companion.'); const p = getPlayer(g, shipment.player); requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g, p) && shipment.turn === g.turn && p.faction === 'richese' && p.noField && p.noFieldEvent === shipment.noField.event && p.noFieldBlockedTurn !== g.turn && !p.noField.deployed && shipment.amount === 1 && shipment.elite === 0 && shipment.advisors === false, 'The saved No-Field shipment lost its current unused opportunity or concealed marker.'); integer(shipment.sector, 0, 18, 'No-Field shipment sector'); requireRule(shipment.territory !== MOBILE_STRONGHOLD, 'Richese cannot ship into the mobile stronghold.'); allowedEntry(g, p, shipment.territory, shipment.sector, false, false); noFieldRule(() => deployRicheseNoField(p.noField!, { tokenId: shipment.noField!.tokenId, controller: p.id, location: { territory: shipment.territory, sector: shipment.sector }, })); if (shipment.smugglerCompanion !== undefined) requireRule( JSON.stringify(shipment.smugglerCompanion) === JSON.stringify(quoteSmugglerNoField(g, p.id, shipment.territory)) && shipment.smugglerCompanion !== null, 'The saved Smuggler No-Field companion lost its living trainer, reserve force or empty destination.', ); const cost = reserveShipmentCost({ faction: p.faction, halfRate: shipmentHalfRate(g, p) }, territory(shipment.territory).type, 1); requireRule(Number.isSafeInteger(shipment.cost) && shipment.cost === cost, 'A No-Field with an optional Smuggler force keeps the price of one marker.'); integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current No-Field shipment spice'); integer(shipment.allyPayment, 0, cost, 'No-Field shipment ally payment'); const credit = aidFor(g, p); if (credit) integer(credit.amount, 0, Number.MAX_SAFE_INTEGER, 'Current ally pledge'); if (shipment.allyPayment > 0) requireRule(p.ally && getPlayer(g, p.ally).ally === p.id, 'The declared No-Field contributor is no longer your mutual ally.'); contribution(g, p, cost, shipment.allyPayment); } /** Find a No-Field response while another card has temporarily suspended it. */ function savedNoFieldResponses(g: Game): ResponseWindow[] { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; return contexts.flatMap(context => { const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null]; }).filter((response): response is ResponseWindow => !!response); } /** Bind both private stages, also while a card effect has saved their controls. */ function leaderSkillNoFieldIntegrity(g: Game): boolean { const responses = savedNoFieldResponses(g); const decisions = homeworldSavedDecisions(g); const shipment = g.pendingShipment; if (!(shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined || (g.leaderSkills && shipment?.noField) || responses.some(r => r.noFieldSkillProof !== undefined) || decisions.some(d => 'noFieldSkillProof' in d))) return false; requireRule(shipment, 'The saved No-Field response lost its original shipment.'); validateLeaderSkillNoFieldShipment(g, shipment); const initial = responses.filter(r => r.kind === 'richeseNoField'); const guild = decisions.filter(d => d.kind === 'guildShipment'); requireRule(initial.length + guild.length === 1 && responses.every(r => r.noFieldSkillProof === undefined || r.kind === 'richeseNoField') && decisions.every(d => !('noFieldSkillProof' in d) || d.kind === 'guildShipment'), 'The saved No-Field shipment lost its unique cancellation response or Guild decision.'); for (const response of initial) requireRule(response.owner === shipment.player && response.noFieldSkillProof === shipment.noFieldSkillProof, 'The No-Field response changed its original declaration.'); for (const decision of guild) validateGuildShipmentDecision(g, decision); return true; } function validatePhysicalShipment(g: Game, shipment: PendingShipment) { requireRule(!atomicsShipmentBlocked(g.moritaniAtomics, shipment.territory), 'Atomics Aftermath permanently blocks shipments into this territory.'); if (shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined || (g.leaderSkills && shipment.noField)) validateLeaderSkillNoFieldShipment(g, shipment); if (shipment.smuggler) requireRule( !shipment.source && !shipment.noField && !shipment.alliedNoField && !shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent && ordinaryLeaderSkillModeSupported(g), 'Smuggler shipping with expansion modules is still being integrated.', ); if (shipment.guildSecretEvent) { validateGuildSecretDeclaration(g,shipment.player,shipment.guildSecretEvent); requireRule(!shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.noField && !shipment.alliedNoField && !shipment.source, 'Guild Secret Ally needs its own ordinary physical reserve shipment.'); } if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'reserve',shipment); if (shipment.nexusEvent !== undefined) currentNexusRicheseShipment(g,shipment); if (shipment.richesePair) currentRichesePairShipment(g, shipment); requireRule( !(shipment.noField || shipment.alliedNoField) || shipment.homeworldSources === undefined, 'A No-Field shipment uses its own physical source allocation.', ); if (shipment.source === 'ambassador') { validateAmbassadorShipmentOrder(g, shipment); return; } // Concealed and allied No-Fields retain their separate custody/quote contracts. if (shipment.noField || shipment.alliedNoField) return; const p = getPlayer(g, shipment.player); requireRule( g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g,p) && (shipment.turn === undefined || shipment.turn === g.turn), 'This shipment no longer belongs to the current unused shipment opportunity.', ); const n = integer(shipment.amount, 1, p.reserves, 'Shipment forces'); integer(shipment.sector, 0, 18, 'Shipment sector'); eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, shipment.elite); if (g.homeworlds?.custody) homeworldRule(() => quoteNativeReserveWithdrawal( homeworldContext(g), g.homeworlds!.custody!, p.id, { normal: n - shipment.elite, elite: shipment.elite }, shipment.homeworldSources, ), ); else requireRule( shipment.homeworldSources === undefined, 'Homeworld source selection requires the Homeworld module.', ); requireRule( typeof shipment.elite === 'number' && shipment.advisors === arrivalAsAdvisor(g, p, shipment.territory), 'The declared force allocation or arrival stance is no longer current.', ); requireRule( shipment.territory !== MOBILE_STRONGHOLD || p.faction === 'ixians', 'Only Ixians may ship directly into the mobile stronghold.', ); allowedEntry( g, p, shipment.territory, shipment.sector, !shipment.guildSecretEvent && g.advanced && p.faction === 'fremen', shipment.advisors, ); if (p.faction === 'fremen' && !shipment.guildSecretEvent) requireRule( territory('the_great_flat').sectors.some((from) => territory(shipment.territory).sectors.some( (to) => distance( location('the_great_flat', from), location(shipment.territory, to), ) <= 2, ), ), 'Fremen reinforcements must arrive within two territories of the Great Flat.', ); const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)}; if (shipment.smuggler) requireRule( !shipment.guildSecretEvent && !shipment.nexusEvent && JSON.stringify(shipment.smuggler) === JSON.stringify(quoteSmugglerShipment(g, p.id, shipment.territory, n)), 'The saved Smuggler shipment lost its living trainer, empty destination or physical amount.', ); const cost = shipment.guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(shipment.territory).type,n)).cost : shipment.nexusEvent ? nexusRule(() => quoteNexusRicheseShipment(price,territory(shipment.territory).type,n)).cost : reserveShipmentCost(price,territory(shipment.territory).type,n - (shipment.smuggler ? 1 : 0)); requireRule( Number.isSafeInteger(shipment.cost) && shipment.cost === cost, 'The declared shipment price is no longer current.', ); integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current shipment spice'); integer(shipment.allyPayment, 0, cost, 'Shipment ally payment'); const credit = aidFor(g, p); if (credit) integer(credit.amount, 0, Number.MAX_SAFE_INTEGER, 'Current ally pledge'); if (shipment.allyPayment > 0) requireRule( p.ally && getPlayer(g, p.ally).ally === p.id, 'The declared shipment contributor is no longer your mutual ally.', ); contribution(g, p, cost, shipment.allyPayment); } function validateGuildShipmentDecision( g: Game, decision: Extract, ) { const shipment = g.pendingShipment; const guild = byFaction(g, 'guild'); requireRule( shipment && g.advanced && guild?.id === decision.player && !guild.specialKaramaUsed && decision.shipper === shipment.player && getPlayer(g, shipment.player).faction !== 'fremen' && decision.territory === shipment.territory && decision.sector === shipment.sector && decision.noFieldSkillProof === shipment.noFieldSkillProof && decision.amount === (shipment.alliedNoField ? 1 : shipment.amount), 'The Guild decision does not match the pending shipment.', ); validatePhysicalShipment(g, shipment); return shipment; } const CLASSIC_FACTIONS: Partial> = { atreides: true, harkonnen: true, emperor: true, fremen: true, guild: true, beneGesserit: true, }; /** The independent Karama rate is a card benefit, not Guild's cancelable rate. */ function guildRateModeSupported(g: Game): boolean { return !g.expansions.length && !g.homeworlds && !g.leaderSkills && !g.nexusCards && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && g.players.every(player => CLASSIC_FACTIONS[player.faction] === true); } function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null { const guild = byFaction(g, 'guild'); const p = getPlayer(g, shipment.player); if (!guild || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) || !guildRateModeSupported(g) || g.karamaShipping?.player === p.id || (g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id) || shipment.source || shipment.noField || shipment.alliedNoField || shipment.smuggler || shipment.smugglerCompanion || shipment.guildSecretEvent || shipment.guildNexusEvent || shipment.nexusEvent || shipment.richesePair) return null; const fullCost = reserveShipmentCost({ faction: p.faction, halfRate: false }, territory(shipment.territory).type, shipment.amount); return shipment.cost < fullCost ? fullCost : null; } function openGuildRate(g: Game, shipment: PendingShipment): boolean { const fullCost = ordinaryGuildRateFullCost(g, shipment); if (fullCost === null) return false; // A validated older Guild-stop declaration may lack this newer turn stamp. shipment.turn ??= g.turn; const event = crypto.randomUUID(); shipment.guildRateEvent = event; g.pendingShipment = shipment; g.response = { kind: 'guildRate', owner: byFaction(g, 'guild')!.id, passed: [], guildRateEvent: event, intent: `${getPlayer(g, shipment.player).name} declared ${shipment.amount} reserve forces to ${territory(shipment.territory).name} at the Guild rate of ${shipment.cost} spice. Karama can remove this discount before any payment or arrival. The original forces would cost ${fullCost} at full price; if the original authorized split cannot pay that amount, the declaration returns unused and a replacement this turn uses full price.`, }; log(g, `${getPlayer(g, shipment.player).name} declared a Guild-rate reserve shipment; payment and arrival wait for the rate response.`); return true; } function validateGuildRateResponse(g: Game, response: ResponseWindow) { const shipment = g.pendingShipment; const guild = byFaction(g, 'guild'); const p = shipment && getPlayer(g, shipment.player); const fullCost = shipment ? ordinaryGuildRateFullCost(g, shipment) : null; requireRule(response.kind === 'guildRate' && guild && p && shipment && g.status === 'playing' && g.phase === 5 && g.active === p.id && !p.shipped && response.owner === guild.id && typeof shipment.guildRateEvent === 'string' && shipment.guildRateEvent.length > 0 && response.guildRateEvent === shipment.guildRateEvent && shipment.turn === g.turn && fullCost !== null && shipment.cost === reserveShipmentCost({ faction: p.faction, halfRate: true }, territory(shipment.territory).type, shipment.amount), 'The saved Guild-rate response lost its original physical shipment.'); return { shipment, fullCost }; } function guildRateIntegrity(g: Game) { const responses = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate'); const expected = Number(!!g.pendingShipment?.guildRateEvent) + Number(!!g.pendingGuildTransport); requireRule(expected <= 1 && responses.length === expected, 'The saved Guild-rate shipment lost its single Karama response.'); if (responses.length) { if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, responses[0]); else validateGuildRateResponse(g, responses[0]); } const blocked = g.guildRateBlocked; if (blocked) requireRule(blocked.turn === g.turn && g.players.some(p => p.id === blocked.player), 'The canceled Guild discount belongs to another turn or player.'); } function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: boolean) { const { shipment, fullCost } = validateGuildRateResponse(g, response); const shipper = getPlayer(g, shipment.player); g.pendingShipment = null; delete shipment.guildRateEvent; if (!canceled) { commitShipment(g, shipment); return; } g.guildRateBlocked = { turn: g.turn, player: shipper.id }; const bounds = shipmentPaymentBounds(fullCost, shipper.spice, aidFor(g, shipper)?.amount ?? 0); if (shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) { log(g, `Karama removed Guild's half-price rate from ${shipper.name}'s declaration. Its original approved payment split cannot fund the full price, so no spice or forces were spent and shipment remains available at full price this turn.`); return; } shipment.cost = fullCost; checkShipmentIncomeRounding(g, shipper, fullCost, shipment.allyPayment); commitShipment(g, shipment); delete g.guildRateBlocked; log(g, `Karama removed Guild's half-price rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged shipment.`); } function guildTransportSignature(intent: PendingGuildTransport): string { return JSON.stringify([intent.event, intent.turn, intent.player, intent.fromReserves, intent.group, intent.eliteGroup, intent.origin, intent.to, intent.sector, intent.amount, intent.elite, intent.cost, intent.allyPayment, intent.advisors]); } function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTransportOrder): number | null { const guild = byFaction(g, 'guild'); if (!guild || !guildRateModeSupported(g) || (p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id)) || g.karamaShipping?.player === p.id || (g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id)) return null; const fullCost = guildShipmentCost( frame.to === 'reserves' ? 'reserves' : territory(frame.to).type, frame.amount, false); return frame.turn === g.turn && frame.cost < fullCost ? fullCost : null; } function openGuildTransportRate(g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean): boolean { const fullCost = ordinaryGuildTransportFullCost(g, p, frame); if (fullCost === null) return false; const intent: PendingGuildTransport = { ...frame, fromReserves, event: crypto.randomUUID(), signature: '', }; intent.signature = guildTransportSignature(intent); g.pendingGuildTransport = intent; g.response = { kind: 'guildRate', owner: byFaction(g, 'guild')!.id, passed: [], guildRateEvent: intent.event, intent: `${p.name} declared ${frame.amount} forces from ${fromReserves ? 'southern reserves' : territory(frame.origin).name} to ${frame.to === 'reserves' ? 'reserves' : territory(frame.to).name} at the Guild transport rate of ${frame.cost} spice. Karama can remove only the discount before payment or force transfer. The same physical transport would cost ${fullCost} at full price; if its original approved payment split cannot cover that, it returns unused and a replacement this turn uses full price.`, }; log(g, `${p.name} declared Guild-rate transport; payment and physical transfer wait for the Karama response.`); return true; } function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) { const intent = g.pendingGuildTransport; const guild = byFaction(g, 'guild'); const shipper = intent && g.players.find(p => p.id === intent.player); const fullCost = intent && shipper ? ordinaryGuildTransportFullCost(g, shipper, intent) : null; requireRule(response.kind === 'guildRate' && intent && guild && shipper && g.status === 'playing' && g.phase === 5 && g.active === shipper.id && !shipper.shipped && intent.turn === g.turn && typeof intent.event === 'string' && intent.event.length > 0 && response.owner === guild.id && response.guildRateEvent === intent.event && typeof intent.fromReserves === 'boolean' && Array.isArray(intent.group) && intent.group.every(([key, n]) => typeof key === 'string' && key.length > 0 && Number.isSafeInteger(n) && n > 0) && intent.eliteGroup && typeof intent.eliteGroup === 'object' && !Array.isArray(intent.eliteGroup) && typeof intent.origin === 'string' && intent.origin.length > 0 && typeof intent.to === 'string' && intent.to.length > 0 && Number.isSafeInteger(intent.sector) && Number.isSafeInteger(intent.amount) && intent.amount > 0 && Number.isSafeInteger(intent.elite) && intent.elite >= 0 && intent.elite <= intent.amount && Number.isSafeInteger(intent.allyPayment) && intent.allyPayment >= 0 && intent.allyPayment <= intent.cost && typeof intent.advisors === 'boolean' && (intent.fromReserves ? intent.origin === 'reserves' && shipper.faction === 'fremen' && intent.group.length === 0 && Object.keys(intent.eliteGroup).length === 0 && intent.to !== 'reserves' : intent.group.length > 0 && intent.group.every(([key]) => splitLocation(key).territory === intent.origin) && intent.group.reduce((sum, [, n]) => sum + n, 0) === intent.amount && Object.keys(intent.eliteGroup).length === intent.group.length && intent.group.every(([key, n]) => Number.isSafeInteger(intent.eliteGroup[key]) && intent.eliteGroup[key] >= 0 && intent.eliteGroup[key] <= n) && intent.group.reduce((sum, [key]) => sum + intent.eliteGroup[key], 0) === intent.elite) && (intent.to !== 'reserves' || shipper.faction === 'guild') && typeof intent.signature === 'string' && intent.signature === guildTransportSignature(intent) && fullCost !== null && intent.cost === guildShipmentCost( intent.to === 'reserves' ? 'reserves' : territory(intent.to).type, intent.amount), 'The saved Guild transport lost its original physical rate response.'); return { intent, shipper, fullCost }; } function validateGuildTransportForCommit( g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean, ) { const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame; requireRule(g.status === 'playing' && g.phase === 5 && frame.turn === g.turn && frame.player === p.id && g.active === p.id && shipmentAvailable(g, p), 'This Guild transport no longer belongs to an unused shipment opportunity.'); if (fromReserves) { requireRule(p.faction === 'fremen' && origin === 'reserves' && group.length === 0 && Object.keys(eliteGroup).length === 0, 'The southern-reserve transport lost its original source.'); integer(amount, 1, p.reserves, 'Forces'); requireRule(eliteChoice(amount, p.reserves, p.elites?.reserves ?? 0, elite) === elite, 'The original southern-reserve elite allocation is unavailable.'); } else { const quoted = forceGroup(g, p, { type: 'guildShip', forces: Object.fromEntries(group), eliteForces: eliteGroup }); requireRule(JSON.stringify(quoted.group) === JSON.stringify(group) && JSON.stringify(quoted.eliteGroup) === JSON.stringify(eliteGroup) && quoted.origin === origin && quoted.elite === elite && quoted.total === amount && group.every(([key]) => splitLocation(key).sector !== g.storm), 'The original Guild transport force group changed or entered the storm.'); } requireRule((fromReserves || to === 'reserves' || to !== origin) && (to !== 'reserves' || p.faction === 'guild') && (to !== MOBILE_STRONGHOLD || p.faction === 'ixians') && advisors === (to !== 'reserves' && arrivalAsAdvisor(g, p, to)), 'The original Guild transport destination or advisor stance is unavailable.'); const sourceLock = p.advisors?.[origin]?.lockedTurn; requireRule(to === 'reserves' || advisors || sourceLock !== g.turn || !g.players.some(other => other.id !== p.id && at(other, to)), 'New advisors cannot become fighters this turn.'); if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors); const halfRate = g.karamaShipping?.player === p.id || !(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id); requireRule(cost === guildShipmentCost( to === 'reserves' ? 'reserves' : territory(to).type, amount, halfRate), 'The declared Guild transport price no longer matches its rate.'); contribution(g, p, cost, allyPayment); checkShipmentIncomeRounding(g, p, cost, allyPayment); checkShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null); } function commitGuildTransport( g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean, guildSecretEvent?: string, ) { const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame; const sourceLock = p.advisors?.[origin]?.lockedTurn; if (guildSecretEvent) recordGuildSecretShipment(g, p.id, guildSecretEvent, 'cross', { ...frame, guildSecretEvent }); bindGuildCunningShipment(g, p, 'guild', frame); payWithAlly(g, p, cost, allyPayment); const guild = byFaction(g, 'guild'); const guildPayment = guildShipmentIncome({ guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment, bankOnly: g.karamaShipping?.player === p.id, }); if (guild && guildPayment > 0) g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment), allyPayment ? undefined : p.id); if (fromReserves) { p.reserves -= amount; if (p.elites) p.elites.reserves -= elite; } else removeGroup(p, group, eliteGroup); if (to === 'reserves') { p.reserves += amount; if (p.elites) p.elites.reserves += elite; } else { place(p, to, sector, amount, elite); if (advisors) { const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0); (p.advisors ??= {})[to] = lockedTurn ? { lockedTurn } : {}; } else if (p.advisors) delete p.advisors[to]; } observeOccupation(g); finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null); finishGuildCunningShipment(g, p.id, 'guild', frame); p.shipped = true; g.karamaShipping = null; log(g, `${p.name} used Guild transport for ${amount} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`, guildSecretEvent ? { faction: 'guild', name: 'Secret Ally transport' } : { faction: p.faction, name: 'Guild transport' }); if (to !== 'reserves') { intrusion(g, p, to); if (origin !== to) openTerritoryEntry(g, p, to, sector, amount, elite, 'guildTransport'); } } function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, canceled: boolean) { const { intent, shipper, fullCost } = validateGuildTransportRateResponse(g, response); g.pendingGuildTransport = null; if (canceled) g.guildRateBlocked = { turn: g.turn, player: shipper.id }; const frame: GuildTransportOrder = { turn: intent.turn, player: intent.player, origin: intent.origin, group: intent.group, eliteGroup: intent.eliteGroup, to: intent.to, sector: intent.sector, amount: intent.amount, elite: intent.elite, cost: canceled ? fullCost : intent.cost, allyPayment: intent.allyPayment, advisors: intent.advisors, }; try { validateGuildTransportForCommit(g, shipper, frame, intent.fromReserves); } catch (error) { if (!(error instanceof RuleError)) throw error; log(g, `${shipper.name}'s Guild transport declaration returned without a payment or force transfer; its exact original source, destination or approved funding can no longer complete at ${canceled ? 'the full canceled rate' : 'the Guild rate'}.${canceled ? ' The spent Karama leaves a replacement at full price for this turn.' : ''}`); return; } commitGuildTransport(g, shipper, frame, intent.fromReserves); if (canceled) { delete g.guildRateBlocked; log(g, `Karama removed Guild's half-price transport rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged physical transport.`); } } function offerShipment(g: Game, shipment: PendingShipment) { validatePhysicalShipment(g, shipment); validateShipmentArrival(g, shipment); checkShipmentIncomeRounding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment); const p = getPlayer(g, shipment.player), id = p.id; const { territory: to, sector: s } = shipment; const n = shipment.alliedNoField ? 1 : shipment.amount; const guild = byFaction(g, 'guild'); if ( g.advanced && g.phase === 5 && guild && !guild.specialKaramaUsed && p.faction !== 'fremen' ) { // Offer independently of the Guild's hidden hand. No payment or arrival // effects occur until this decision has resolved. g.pendingShipment = shipment; g.decision = { kind: 'guildShipment', player: guild.id, shipper: id, territory: to, sector: s, amount: n, ...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}), }; log( g, `${p.name} declared a shipment of ${shipment.noField ? `one concealed No-Field${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''}` : `${n} forces`} to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`, ); } else if (!openGuildRate(g, shipment)) commitShipment(g, shipment); } function commitShipment(g: Game, shipment: PendingShipment) { if (shipment.source === 'ambassador') { commitAmbassadorShipment(g, shipment); return; } validatePhysicalShipment(g, shipment); validateShipmentArrival(g, shipment); if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'reserve',shipment); finishNexusRicheseShipment(g,shipment,'shipped'); finishRichesePairShipment(g, shipment, 'shipped'); if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'reserve',shipment); finishShipmentPromises( g, getPlayer(g, shipment.player), shipment.noField || shipment.alliedNoField ? null : shipment, ); const { territory: to, sector: s, amount: n, elite, cost, allyPayment, advisors, } = shipment; const p = getPlayer(g, shipment.player); let pairRevealed: ReturnType['revealed'] | null = null; if (shipment.noField) { requireRule( p.faction === 'richese' && p.noField && p.noFieldEvent === shipment.noField.event, 'This No-Field shipment is no longer current.', ); if (shipment.richesePair) { const pair = noFieldRule(() => deployRicheseNoFieldPair(p.noField!, { tokenId: shipment.noField!.tokenId, controller: p.id, location: { territory: to, sector: s }, }, shipment.richesePair!.revealedTokenId, p.reserves)); p.noField = pair.state; pairRevealed = pair.revealed; } else p.noField = noFieldRule(() => deployRicheseNoField(p.noField!, { tokenId: shipment.noField!.tokenId, controller: p.id, location: { territory: to, sector: s }, })); p.noFieldEvent = crypto.randomUUID(); } if (shipment.alliedNoField) { const offer = shipment.alliedNoField; let quote: ReturnType; try { quote = alliedNoFieldQuote(g, offer); requireRule( quote.amount === n && quote.cost === cost && elite >= quote.eliteMin && elite <= quote.eliteMax, 'The offered reserve allocation changed before shipment.', ); } catch (error) { if (!(error instanceof RuleError)) throw error; g.pendingShipment = null; log( g, 'The allied No-Field declaration became unavailable before commitment. No shipment, payment or token reveal occurred; the recipient may ship normally.', ); return; } const owner = getPlayer(g, offer.owner); if (owner.noField!.deployed) revealPlayerNoField(g, owner, 'beforeAlly'); const result = noFieldRule(() => shipAlliedRicheseNoField( owner.noField!, { tokenId: offer.tokenId, controller: p.id, location: { territory: to, sector: s }, }, p.reserves, ), ); owner.noField = result.state; owner.noFieldEvent = crypto.randomUUID(); owner.spice -= quote.ownerPayment; p.spice -= quote.recipientPayment; } else payWithAlly(g, p, cost, allyPayment); if (pairRevealed) { p.reserves -= pairRevealed.forces; if (pairRevealed.forces) place(p, to, s, pairRevealed.forces); observeOccupation(g); } let homeworldOrigins = ''; if (!shipment.noField) { const receipts = withdrawNativeReserves( g, p, n, elite, shipment.homeworldSources, ); homeworldOrigins = receipts .map((receipt) => { const card = HOMEWORLD_CARDS.find((c) => receipt.homeworld === 'homeworld:emperor:salusa' ? c.id === 'salusa_secundus' : c.faction === p.faction && c.id !== 'salusa_secundus', )!; return `${receipt.before.normal - receipt.after.normal} normal and ${receipt.before.elite - receipt.after.elite} special forces from ${card.name}`; }) .join('; '); if (n > 0) place(p, to, s, n, elite); } if (shipment.smugglerCompanion) { withdrawNativeReserves(g, p, 1, 0); place(p, to, s, 1, 0); } if (advisors && n > 0) (p.advisors ??= {})[to] ??= {}; p.shipped = true; if (p.faction !== 'fremen') techIncome(g, 'heighliners', p); const guild = byFaction(g, 'guild'); const guildPayment = guildShipmentIncome({ guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment, bankOnly: g.karamaShipping?.player === p.id, }); if (guild && guildPayment > 0) g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id); g.karamaShipping = null; log( g, shipment.alliedNoField ? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.` : shipment.noField ? pairRevealed ? `${p.name} shipped two No-Fields with Richese Nexus Cunning to ${territory(to).name}, sector ${s}, for ${cost} spice. The faceup ${pairRevealed.value} token placed ${pairRevealed.forces} physical forces from reserves${pairRevealed.forces < pairRevealed.value ? ' (only the available reserves)' : ''}; the other token remains concealed and counts as one force.` : `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. ${shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}` : `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.guildSecretEvent ? ` Guild Secret Ally is spent; Guild shipping prices cost ${cost} spice paid to the bank.` : ''}${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`, shipment.noField || shipment.alliedNoField ? { faction: 'richese', name: 'No-Field shipment' } : shipment.guildSecretEvent ? {faction:'guild',name:'Secret Ally shipment'} : shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : shipment.smuggler ? undefined : { faction: p.faction, name: 'Shipment' }, ); if (shipment.smuggler) log(g, `${p.name}'s Smuggler included one free accompanying force: ${n} physical forces left reserves, priced as ${n - 1} for ${cost} spice. The destination was empty before arrival.`, { faction: p.faction, name: 'Smuggler shipment' }); const bg = byFaction(g, 'beneGesserit'); const followup = p.faction !== 'fremen' && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0 ? { shipment: p.id, destination: location(to, s) } : undefined; if (!intrusion(g, p, to, followup ? { followup } : undefined) && followup) g.decision = { kind: 'advisor', player: bg!.id, ...followup }; if (g.advanced && p.faction === 'fremen' && s === g.storm) g.response = { kind: 'stormProtection', owner: p.id, passed: [], resume: 'shipment', location: location(to, s), amount: n, elite, }; openTerritoryEntry( g, p, to, s, shipment.alliedNoField ? Math.max(1, n) : n + (shipment.smugglerCompanion ? 1 : 0) + (pairRevealed?.forces ?? 0), elite, 'shipment', ); } function quoteAlliedTokenValue(g: Game, offer: RicheseAllyOffer) { return getPlayer(g, offer.owner).noField!.tokens.find( (t) => t.id === offer.tokenId, )!.value; } function validateMobileMove(g: Game, p: Player, input: unknown, max: number) { const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id)); requireRule(!blocked, blocked ?? 'The mobile stronghold cannot move.'); requireRule( p.faction === 'ixians' && g.mobileStronghold?.location && at(p, MOBILE_STRONGHOLD) > 0, 'Ixian forces must occupy the mobile stronghold to move it.', ); requireRule( Array.isArray(input) && input.every((key) => typeof key === 'string'), 'Declare a route of board sectors.', ); const route = input as string[]; requireRule( route.every((key) => splitLocation(key).sector !== g.storm), 'The mobile stronghold cannot move into, out of or through the storm.', ); requireRule( route.length > 1 && route[0] === g.mobileStronghold.location && mobileRouteDistance(route) <= max, `Choose a connected route of at most ${max} territories, starting at the stronghold.`, ); requireRule( territory(splitLocation(route.at(-1)!).territory).type !== 'stronghold', 'The mobile stronghold must finish in a non-stronghold territory.', ); return route; } function relocateMobileStronghold( g: Game, move: { player: string; route: string[]; collect: boolean }, ) { const p = getPlayer(g, move.player); const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id)); requireRule(!blocked, blocked ?? 'The mobile stronghold cannot move.'); let collected = 0; if (move.collect) for (const key of move.route) { const amount = Math.min(g.spice[key] ?? 0, at(p, MOBILE_STRONGHOLD) * 2); if (amount) { g.spice[key] -= amount; p.spice += amount; collected += amount; } } g.mobileStronghold!.location = move.route.at(-1)!; const loc = splitLocation(g.mobileStronghold!.location); log( g, `${p.name} moved the Hidden Mobile Stronghold to ${territory(loc.territory).name}, sector ${loc.sector}, collecting ${collected} spice.`, { faction: p.faction, name: 'Hidden Mobile Stronghold' }, ); } function choamWorthlessBlocked(g: Game, card: string) { return ( g.choamWorthlessBlocked?.turn === g.turn && g.choamWorthlessBlocked.phase === g.phase && g.choamWorthlessBlocked.cards.includes(card) ); } function currentGuildCunning(g: Game, owner?: string) { const last = g.nexusGuildCunningHistory?.at(-1); return last && last.receipt.turn === g.turn && (!owner || last.receipt.owner === owner) && ['pending','secondShipment','extraMove'].includes(last.stage) ? last : null; } function guildCunningParent(g: Game) { return JSON.stringify([g.active,g.order,g.movementRemaining,g.guildTimingGranted,g.guildTimingLocked,g.saphoMovementLast ?? null]); } function guildCunningSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature,record.stage,record.parent,record.movesAfter,record.shipment ?? null]); } function saveGuildCunning(g: Game, record: NonNullable[number]) { record.signature = guildCunningSignature(record); g.nexusGuildCunningLast = {event:record.receipt.event,stage:record.stage}; } function projectedNexusGuildCunning(g: Game, owner: string) { const p = g.players.find(p => p.id === owner); if (!p || p.faction !== 'guild') return null; const record = currentGuildCunning(g,owner); if (record) return {offer:null,active:{event:record.receipt.event,stage:record.stage as 'pending'|'secondShipment'|'extraMove', movesLeft:record.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(record.receipt,g.hajr.includes(owner),record.movesAfter)) : 0, hajrAvailable:record.stage !== 'pending' && !g.hajr.includes(owner)}}; if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null; let blocked: string | null = null; if (p.ally) blocked = 'Guild Cunning requires an unallied native Guild player.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner) blocked = 'Use Cunning when finishing your own Shipment and Movement turn.'; else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingShipment || g.pendingHomeworldShipment || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the current interaction before declaring a second shipment.'; else if (g.ornithopter || p.moved > (g.hajr.includes(owner) ? 2 : 1)) blocked = 'Cunning combined with the physical Ornithopter movement card awaits its timing ruling.'; else if (g.nexusGuildCunningHistory?.some(r => r.receipt.turn === g.turn && r.receipt.owner === owner)) blocked = 'This turn’s Guild Cunning opportunity is already used.'; return {offer:{event:JSON.stringify(['nexusGuildCunning',g.turn,owner]),blocked},active:null}; } function declareGuildCunning(g: Game, p: Player, event: unknown) { const offer = projectedNexusGuildCunning(g,p.id)?.offer; requireRule(offer && !offer.blocked && event === offer.event,offer?.blocked ?? 'This Guild Cunning opportunity is stale.'); const receipt = nexusRule(() => createNexusGuildCunning(g,p.id,{shipped:p.shipped,moved:p.moved,hajrUsed:g.hajr.includes(p.id)})); const record = {receipt,stage:'pending' as const,parent:guildCunningParent(g),movesAfter:0,signature:''}; g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); (g.nexusGuildCunningHistory ??= []).push(record); saveGuildCunning(g,record); g.karamaShipping = null; g.response = {kind:'nexusGuildCunning',owner:p.id,intent:receipt.event,passed:[]}; log(g,`${p.name} finished the ordinary shipment and movement opportunity and played Guild Cunning for an immediate second paid shipment. The Nexus is spent; Hajr is required for any further movement.`); } function validateGuildCunningResponse(g: Game, response: ResponseWindow) { const record = currentGuildCunning(g,response.owner); requireRule(record?.stage === 'pending' && response.kind === 'nexusGuildCunning' && response.intent === record.receipt.event && record.parent === guildCunningParent(g), 'Guild Cunning has lost its original combined-turn response.'); return record; } function settleGuildCunning(g: Game, response: ResponseWindow, canceled: boolean) { const record = validateGuildCunningResponse(g,response); record.stage = canceled ? 'canceled' : 'secondShipment'; saveGuildCunning(g,record); log(g,canceled ? 'Karama prevented Guild Cunning. The first shipment and movement remain completed and the Nexus remains spent.' : 'Guild Cunning permits one immediate paid shipment using normal Guild routes. Ordinary movement is finished; an unused Hajr move remains possible afterward.', canceled ? undefined : {faction:'guild',name:'Cunning: second shipment'}); if (canceled) finishMovementTurn(g,record.receipt.owner); } function bindGuildCunningShipment(g: Game, p: Player, kind: 'reserve'|'guild'|'homeworld', frame: unknown) { const record = currentGuildCunning(g,p.id); if (!record) return; requireRule(record.stage === 'secondShipment' && !record.shipment, 'Guild Cunning already has its second shipment.'); record.shipment = {kind,frame:JSON.stringify(frame),stage:'pending'}; saveGuildCunning(g,record); } function validateGuildCunningShipment(g: Game, owner: string, event: string, kind: 'reserve'|'guild'|'homeworld', frame: unknown) { const record = currentGuildCunning(g,owner); requireRule(record?.stage === 'secondShipment' && record.receipt.event === event && record.shipment?.stage === 'pending' && record.shipment.kind === kind && record.shipment.frame === JSON.stringify(frame), 'Guild Cunning has lost its original second shipment.'); return record; } function finishGuildCunningShipment(g: Game, owner: string, kind: 'reserve'|'guild'|'homeworld', frame: unknown, outcome:'complete'|'stopped' = 'complete') { const record = currentGuildCunning(g,owner); if (!record) return; validateGuildCunningShipment(g,owner,record.receipt.event,kind,frame); record.shipment!.stage = outcome; record.stage = 'extraMove'; saveGuildCunning(g,record); } function nexusGuildCunningIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(c => { if (!c) return []; const karama = 'pendingKarama' in c ? c.pendingKarama as Game['pendingKarama'] : null; return [c.response,karama?.use?.kind === 'cancel' ? karama.use.response : null]; }).filter(r => r?.kind === 'nexusGuildCunning'); const history = g.nexusGuildCunningHistory; if (history === undefined) { requireRule(!g.nexusGuildCunningLast && !responses.length && !g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent, 'Guild Cunning has lost its saved second-shipment history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,'Guild Cunning requires its original Nexus module and history.'); const events = new Set(); for (const [index,record] of history.entries()) { requireRule(record && typeof record === 'object' && Object.keys(record).every(k => ['receipt','stage','parent','movesAfter','shipment','signature'].includes(k)) && ['pending','secondShipment','extraMove','completed','canceled'].includes(record.stage) && typeof record.parent === 'string' && record.signature === guildCunningSignature(record) && !events.has(record.receipt?.event) && Number.isSafeInteger(record.movesAfter) && record.movesAfter >= 0 && record.movesAfter <= 1 && (['completed','canceled'].includes(record.stage) || index === history.length - 1),'Guild Cunning has a changed or duplicated allowance record.'); nexusRule(() => validateNexusGuildCunning(g,record.receipt)); if (record.shipment?.kind === 'declined') requireRule(Object.keys(record.shipment).sort().join(',') === 'kind,stage' && record.shipment.stage === 'declined' && ['extraMove','completed'].includes(record.stage), 'Guild Cunning has changed its declined second shipment.'); else if (record.shipment) requireRule(Object.keys(record.shipment).sort().join(',') === 'frame,kind,stage' && ['reserve','guild','homeworld'].includes(record.shipment.kind) && typeof record.shipment.frame === 'string' && ['pending','complete','stopped'].includes(record.shipment.stage) && (record.shipment.stage === 'pending' ? record.stage === 'secondShipment' : ['extraMove','completed'].includes(record.stage)), 'Guild Cunning has changed its second shipment outcome.'); requireRule(record.stage !== 'extraMove' || (record.shipment && record.shipment.stage !== 'pending'), 'Guild Cunning has lost its completed second shipment.'); requireRule(record.stage !== 'pending' || (!record.shipment && !record.movesAfter), 'Guild Cunning changed before its response completed.'); requireRule(!record.movesAfter || record.stage === 'extraMove' || record.stage === 'completed','Guild Cunning has moved before its second shipment.'); events.add(record.receipt.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusGuildCunningLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}), 'Guild Cunning has lost its latest allowance marker.'); const active = currentGuildCunning(g); requireRule(['completed','canceled'].includes(last.stage) || active, 'Guild Cunning has left its original turn unfinished.'); requireRule((active?.stage === 'pending') === !!responses.length, 'Guild Cunning has lost or reopened its native response.'); if (!active) { requireRule(!g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent,'Guild Cunning has reopened a completed shipment.'); return; } const owner = getPlayer(g,active.receipt.owner); requireRule(g.status === 'playing' && g.phase === 5 && g.active === owner.id && active.parent === guildCunningParent(g) && owner.moved === active.receipt.before.moved + active.movesAfter && owner.shipped === (active.stage === 'extraMove' && (active.shipment?.kind !== 'declined' || active.movesAfter > 0) ? true : active.receipt.before.shipped) && g.nexusCards.cards.discard.includes('guild'), 'Guild Cunning changed its original active player, queue, shipment or movement allowance.'); nexusRule(() => nexusGuildCunningMoves(active.receipt,g.hajr.includes(owner.id),active.movesAfter)); for (const response of responses) validateGuildCunningResponse(g,response!); if (active.shipment?.stage === 'pending') { if (active.shipment.kind === 'reserve') { requireRule(g.pendingShipment?.guildNexusEvent === active.receipt.event, 'Guild Cunning lost its pending reserve shipment.'); validateGuildCunningShipment(g,owner.id,active.receipt.event,'reserve',g.pendingShipment); const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment'); requireRule(decisions.length > 0,'Guild Cunning lost its shipment interception decision.'); for (const d of decisions) validateGuildShipmentDecision(g,d); } else if (active.shipment.kind === 'homeworld') { requireRule(g.pendingHomeworldShipment?.guildNexusEvent === active.receipt.event,'Guild Cunning lost its pending Homeworld shipment.'); validateGuildCunningShipment(g,owner.id,active.receipt.event,'homeworld',g.pendingHomeworldShipment); homeworldShipmentIntegrity(g); } else throw new RuleError('Guild Cunning retained an unfinished immediate cross-shipment.'); } else requireRule(!g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent, 'Guild Cunning has a shipment without its original pending record.'); } function nexusGuildSecretAllyOffer(g: Game, owner: string) { const p = g.players.find(p => p.id === owner); if (!p || g.nexusCards?.cards?.hands[owner] !== 'guild' || byFaction(g,'guild')) return null; let blocked: string | null = null; if (p.ally) blocked = 'Guild Secret Ally requires an unallied holder.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || !shipmentAvailable(g,p)) blocked = 'Use Guild Secret Ally for your unused shipment before moving.'; else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the current interaction before declaring this shipment.'; else if (g.nexusGuildSecretHistory?.some(r => r.receipt.owner === owner && r.receipt.turn === g.turn)) blocked = 'Guild Secret Ally has already been used this turn.'; return {event:JSON.stringify(['nexusGuildSecretAlly',g.turn,owner]),blocked}; } function validateGuildSecretDeclaration(g: Game, owner: string, event: unknown) { const offer = nexusGuildSecretAllyOffer(g,owner); requireRule(offer && !offer.blocked && event === offer.event, offer?.blocked ?? 'This Guild Secret Ally shipment is unavailable or stale.'); return offer.event; } function guildSecretSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature,record.kind,record.frame]); } function recordGuildSecretShipment(g: Game, owner: string, event: string, kind: 'reserve'|'cross'|'homeworld', frame: object) { validateGuildSecretDeclaration(g,owner,event); const receipt = nexusRule(() => createNexusGuildSecretAlly(g,owner)); const record = {receipt,kind,frame:JSON.stringify(frame),signature:''}; record.signature = guildSecretSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players)); (g.nexusGuildSecretHistory ??= []).push(record); g.nexusGuildSecretLast = {event}; } function nexusGuildSecretIntegrity(g: Game) { const history = g.nexusGuildSecretHistory; requireRule(!g.pendingShipment?.guildSecretEvent && !g.pendingHomeworldShipment?.guildSecretEvent, 'Absent-Guild Secret Ally cannot retain a native Guild interception.'); if (history === undefined) { requireRule(!g.nexusGuildSecretLast,'Guild Secret Ally has lost its original shipment history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Guild Secret Ally requires its original Nexus module and shipment history.'); const events = new Set(); for (const record of history) { requireRule(record && Object.keys(record).sort().join(',') === 'frame,kind,receipt,signature' && ['reserve','cross','homeworld'].includes(record.kind) && typeof record.frame === 'string' && record.receipt && record.signature === guildSecretSignature(record) && !events.has(record.receipt.event), 'Guild Secret Ally has a changed or duplicated shipment record.'); nexusRule(() => validateNexusGuildSecretAlly(g,record.receipt)); const frame = nexusRule(() => JSON.parse(record.frame)) as Record; requireRule(frame && typeof frame === 'object' && frame.guildSecretEvent === record.receipt.event && frame.player === record.receipt.owner && frame.turn === record.receipt.turn && !frame.nexusEvent && !frame.guildNexusEvent && !frame.noField && !frame.alliedNoField && !frame.source, 'Guild Secret Ally has changed its original shipment source.'); const destination = record.kind === 'homeworld' ? 'homeworld' : record.kind === 'cross' && frame.to === 'reserves' ? 'reserves' : territory(String(record.kind === 'cross' ? frame.to : frame.territory)).type; const quote = nexusRule(() => quoteNexusGuildSecretShipment(destination,frame.amount as number)); requireRule(frame.cost === quote.cost && frame.allyPayment === 0 && Number.isSafeInteger(frame.elite) && Number(frame.elite) >= 0 && Number(frame.elite) <= quote.physicalAmount, 'Guild Secret Ally has changed its original payment or typed allocation.'); events.add(record.receipt.event); } requireRule(JSON.stringify(g.nexusGuildSecretLast) === JSON.stringify({event:history.at(-1)!.receipt.event}), 'Guild Secret Ally has lost its latest shipment outcome.'); } function nexusRicheseOffer(g: Game, owner: string) { const p = g.players.find(p => p.id === owner); if (!p || g.nexusCards?.cards?.hands[owner] !== 'richese' || byFaction(g,'richese')) return null; let blocked: string | null = null; if (p.ally) blocked = 'Richese Secret Ally requires an unallied holder.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || p.shipped) blocked = 'Use Secret Ally for your unused reserve shipment before moving.'; else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the current interaction before declaring this shipment.'; else if (p.reserves < 1) blocked = 'You need physical forces in reserves.'; return {event:JSON.stringify(['nexusRichese',g.turn,owner]),blocked,maxForces:Math.min(5,p.reserves)}; } const RICHESE_CUNNING_ROSTER = new Set([ 'atreides', 'harkonnen', 'emperor', 'fremen', 'beneGesserit', 'guild', 'richese', 'choam', ]); function richeseCunningModeSupported(g: Game) { return !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.sandtrout && g.players.every(player => RICHESE_CUNNING_ROSTER.has(player.faction)); } function nexusRicheseCunningOffer(g: Game, owner: string) { const p = g.players.find(player => player.id === owner); if (!p || p.faction !== 'richese' || g.nexusCards?.cards?.hands[owner] !== 'richese') return null; let blocked: string | null = null; if (!richeseCunningModeSupported(g)) blocked = 'This two-token shipment supports classic factions, Richese and CHOAM without other optional modules.'; else if (p.ally) blocked = 'Nexus Cunning requires an unallied Richese holder.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || !shipmentAvailable(g, p)) blocked = 'Use Cunning for your unused shipment before movement.'; else if (nexusCleanPlayBlocked(g) || g.karamaShipping) blocked = 'Finish the current interaction or Karama shipment before using Cunning.'; else blocked = noFieldShipBlock(g, p); return { event: JSON.stringify(['nexusRicheseCunning', g.turn, owner, p.noFieldEvent]), blocked, }; } function richesePairSignature(record: NonNullable[number]) { return JSON.stringify([record.event, record.owner, record.turn, record.phase, record.tokenEvent, record.frame, record.stage]); } function currentRichesePairShipment(g: Game, shipment: PendingShipment) { const pair = shipment.richesePair; const record = g.nexusRicheseCunningHistory?.at(-1); requireRule(pair && record?.stage === 'pending' && record.event === pair.event && record.frame === JSON.stringify(shipment) && record.owner === shipment.player && record.turn === g.turn && record.phase === 5 && record.tokenEvent === shipment.noField?.event && record.signature === richesePairSignature(record) && g.nexusCards?.cards?.discard.includes('richese') && !shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.guildSecretEvent && !shipment.alliedNoField && !shipment.source && !shipment.smuggler && !shipment.smugglerCompanion && !shipment.homeworldSources && !shipment.noFieldSkillProof && shipment.amount === 1 && shipment.elite === 0 && shipment.advisors === false && shipment.allyPayment === 0 && richeseCunningModeSupported(g), 'Richese Cunning lost its original two-token shipment or physical Nexus card.'); const p = getPlayer(g, shipment.player); requireRule(p.faction === 'richese' && !p.ally && p.noField && p.noFieldEvent === record.tokenEvent && !p.noField.deployed && g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g, p), 'Richese Cunning lost its unused native shipment.'); const cost = reserveShipmentCost({ faction: p.faction, halfRate: false }, territory(shipment.territory).type, 1); requireRule(shipment.cost === cost && Number.isSafeInteger(shipment.sector) && territory(shipment.territory).sectors.includes(shipment.sector), 'Richese Cunning lost its original one-marker price or destination.'); noFieldRule(() => validateRicheseNoFieldPair(p.noField!, { tokenId: shipment.noField!.tokenId, controller: p.id, location: { territory: shipment.territory, sector: shipment.sector }, }, pair.revealedTokenId, p.reserves)); return record; } function recordRichesePairShipment(g: Game, shipment: PendingShipment) { const p = getPlayer(g, shipment.player); const offer = nexusRicheseCunningOffer(g, p.id); requireRule(offer && !offer.blocked && offer.event === shipment.richesePair?.event, offer?.blocked ?? 'Richese Cunning is unavailable or stale.'); const record: NonNullable[number] = { event: offer.event, owner: p.id, turn: g.turn, phase: 5, tokenEvent: shipment.noField!.event, frame: JSON.stringify(shipment), stage: 'pending', signature: '', }; record.signature = richesePairSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); (g.nexusRicheseCunningHistory ??= []).push(record); g.nexusRicheseCunningLast = { event: record.event, stage: record.stage }; } function finishRichesePairShipment(g: Game, shipment: PendingShipment, stage: 'shipped' | 'stopped') { if (!shipment.richesePair) return; const record = currentRichesePairShipment(g, shipment); record.stage = stage; record.signature = richesePairSignature(record); g.nexusRicheseCunningLast = { event: record.event, stage }; } function richesePairIntegrity(g: Game) { const history = g.nexusRicheseCunningHistory; const pending = g.pendingShipment; if (history === undefined) { requireRule(!g.nexusRicheseCunningLast && !pending?.richesePair, 'Richese Cunning lost its saved shipment history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Richese Cunning needs its physical Nexus module and history.'); const events = new Set(); for (const [index, record] of history.entries()) { requireRule(record && typeof record === 'object' && Object.keys(record).sort().join(',') === 'event,frame,owner,phase,signature,stage,tokenEvent,turn' && typeof record.owner === 'string' && typeof record.tokenEvent === 'string' && Number.isSafeInteger(record.turn) && record.turn > 0 && record.turn <= g.turn && record.phase === 5 && ['pending', 'shipped', 'stopped'].includes(record.stage) && typeof record.frame === 'string' && record.signature === richesePairSignature(record) && !events.has(record.event) && (record.stage !== 'pending' || index === history.length - 1) && record.event === JSON.stringify(['nexusRicheseCunning', record.turn, record.owner, record.tokenEvent]), 'Richese Cunning changed its original card, owner or shipment receipt.'); const frame = JSON.parse(record.frame) as PendingShipment; requireRule(frame && typeof frame === 'object' && !Array.isArray(frame) && Object.keys(frame).sort().join(',') === 'advisors,allyPayment,amount,cost,elite,noField,player,richesePair,sector,territory,turn' && frame.richesePair?.event === record.event && frame.player === record.owner && frame.noField?.event === record.tokenEvent && frame.turn === record.turn && typeof frame.noField.tokenId === 'string' && typeof frame.richesePair.revealedTokenId === 'string' && g.players.some(player => player.id === record.owner && player.faction === 'richese' && player.noField?.tokens.some(token => token.id === frame.noField?.tokenId) && player.noField?.tokens.some(token => token.id === frame.richesePair?.revealedTokenId)), 'Richese Cunning changed its two physical token identities.'); events.add(record.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusRicheseCunningLast) === JSON.stringify({ event: last.event, stage: last.stage }) && (last.stage === 'pending') === !!pending?.richesePair, 'Richese Cunning lost or reopened its latest shipment.'); if (last.stage === 'pending') { requireRule(pending && pending.richesePair?.event === last.event, 'Richese Cunning lost its pending shipment.'); currentRichesePairShipment(g, pending); const responses = savedNoFieldResponses(g).filter(response => response.kind === 'richeseNoField'); const decisions = homeworldSavedDecisions(g).filter(decision => decision.kind === 'guildShipment'); requireRule(responses.length + decisions.length === 1, 'Richese Cunning lost its unique Karama response or Guild interception decision.'); for (const response of responses) requireRule(response.owner === pending.player && response.noFieldSkillProof === undefined, 'Richese Cunning changed its native No-Field response.'); for (const decision of decisions) validateGuildShipmentDecision(g, decision); } } function shipmentHalfRate(g: Game, p: Player) { return g.karamaShipping?.player === p.id || ((p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally) && !(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id)); } function nexusRicheseSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature,record.stage,record.frame,record.halfRate]); } function currentNexusRicheseShipment(g: Game, shipment: PendingShipment) { const record = g.nexusRicheseHistory?.find(r => r.receipt.event === shipment.nexusEvent); requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(shipment) && record.receipt.owner === shipment.player && record.receipt.turn === g.turn && !getPlayer(g,shipment.player).ally && record.signature === nexusRicheseSignature(record) && !shipment.noField && !shipment.alliedNoField && !shipment.source, 'Richese Secret Ally has lost its original physical shipment declaration.'); nexusRule(() => validateNexusRichese(g,record.receipt)); return record; } function recordNexusRicheseShipment(g: Game, shipment: PendingShipment) { const p = getPlayer(g,shipment.player); const receipt = nexusRule(() => createNexusRichese(g,p.id)); requireRule(shipment.nexusEvent === receipt.event, 'This Richese Secret Ally selection is stale.'); const record = {receipt,stage:'pending' as const,frame:JSON.stringify(shipment),halfRate:shipmentHalfRate(g,p),signature:''}; record.signature = nexusRicheseSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); (g.nexusRicheseHistory ??= []).push(record); g.nexusRicheseLast = {event:receipt.event,stage:'pending'}; } function finishNexusRicheseShipment(g: Game, shipment: PendingShipment, stage: 'shipped'|'stopped') { if (!shipment.nexusEvent) return; const record = currentNexusRicheseShipment(g,shipment); record.stage = stage; record.signature = nexusRicheseSignature(record); g.nexusRicheseLast = {event:record.receipt.event,stage}; } function nexusRicheseIntegrity(g: Game) { const history = g.nexusRicheseHistory, pending = g.pendingShipment; if (history === undefined) { requireRule(!g.nexusRicheseLast && !pending?.nexusEvent, 'Richese Secret Ally has lost its saved shipment history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Richese Secret Ally requires its original Nexus module and history.'); const events = new Set(); for (const [index,record] of history.entries()) { requireRule(record && typeof record === 'object' && Object.keys(record).sort().join(',') === 'frame,halfRate,receipt,signature,stage' && ['pending','shipped','stopped'].includes(record.stage) && typeof record.frame === 'string' && typeof record.halfRate === 'boolean' && record.signature === nexusRicheseSignature(record) && !events.has(record.receipt?.event) && (record.stage !== 'pending' || index === history.length - 1), 'Richese Secret Ally has a changed or duplicated shipment record.'); nexusRule(() => validateNexusRichese(g,record.receipt)); const frame = nexusRule(() => JSON.parse(record.frame)) as PendingShipment; requireRule(frame && frame.nexusEvent === record.receipt.event && frame.player === record.receipt.owner && frame.turn === record.receipt.turn && !frame.noField && !frame.alliedNoField && !frame.source && Object.keys(frame).every(k => ['nexusEvent','turn','player','territory','sector','amount','elite','cost','allyPayment','advisors','homeworldSources'].includes(k)), 'Richese Secret Ally has changed its original shipment source.'); const quote = nexusRule(() => quoteNexusRicheseShipment({faction:getPlayer(g,frame.player).faction,halfRate:record.halfRate},territory(frame.territory).type,frame.amount)); requireRule(frame.cost === quote.cost && frame.allyPayment === 0 && Number.isSafeInteger(frame.elite) && frame.elite >= 0 && frame.elite <= frame.amount && typeof frame.advisors === 'boolean' && territory(frame.territory).sectors.includes(frame.sector), 'Richese Secret Ally has changed its original physical allocation or price.'); events.add(record.receipt.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusRicheseLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}) && (last.stage === 'pending') === !!pending?.nexusEvent, 'Richese Secret Ally has lost or reopened its latest shipment.'); if (last.stage === 'pending') { requireRule(pending && pending.nexusEvent === last.receipt.event && g.nexusCards.cards.discard.includes('richese'), 'Richese Secret Ally has lost its pending shipment or spent Nexus card.'); currentNexusRicheseShipment(g,pending); const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment'); requireRule(decisions.length > 0, 'Richese Secret Ally has lost its Guild interception decision.'); for (const decision of decisions) validateGuildShipmentDecision(g,decision); } } function nexusMoritaniOffer(g: Game, owner: string, decision: Game['decision'] = g.decision) { const p = g.players.find(p => p.id === owner); if (!p || p.faction !== 'moritani' || g.nexusCards?.cards?.hands[p.id] !== 'moritani') return null; const tokens = g.moritaniTerror?.tokens.filter(t => t.status === 'available') ?? []; let blocked: string | null = null; if (p.ally) blocked = 'Nexus Cunning requires an unallied Moritani player.'; else if (g.status !== 'playing' || g.phase !== 8 || decision?.kind !== 'moritaniPlacement' || decision.player !== p.id) blocked = 'Use Cunning when placing a supply token during your Mentat opportunity.'; else if (g.response || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard) blocked = 'Finish the current interaction before declaring this placement.'; else if (g.moritaniTerror?.placementTurn === g.turn) blocked = 'This turn’s placement opportunity is already used.'; else if (!tokens.length) blocked = 'No supply token is available. Cunning relocation awaits a ruling.'; return {event:JSON.stringify(['nexusMoritani',g.turn,p.id]),blocked,tokens, destinations:TERRITORIES.map(t => t.id)}; } function nexusMoritaniIntegrity(g: Game) { const history = g.nexusMoritaniHistory, pending = g.pendingMoritaniPlacement; if (history === undefined) { requireRule(!g.nexusMoritaniLast && !pending?.nexusEvent, 'Moritani Cunning has lost its saved placement history.'); } else { requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'Moritani Cunning requires its original Nexus module and history.'); const events = new Set(); for (const [index,record] of history.entries()) { requireRule(record && typeof record === 'object' && Object.keys(record).sort().join(',') === 'before,frame,receipt,signature,stage' && ['pending','complete','canceled'].includes(record.stage) && typeof record.before === 'string' && typeof record.frame === 'string' && record.signature === nexusMoritaniRecordSignature(record) && !events.has(record.receipt?.event) && (record.stage !== 'pending' || index === history.length - 1), 'Moritani Cunning has a changed or duplicated placement record.'); nexusRule(() => validateNexusMoritani(g,record.receipt)); const before = nexusRule(() => JSON.parse(record.before)) as TerrorState; nexusRule(() => quoteNexusMoritaniPlacement(before,record.receipt.token,record.receipt.territory,record.receipt.turn)); requireRule(record.frame === JSON.stringify({token:record.receipt.token,territory:record.receipt.territory,turn:record.receipt.turn,nexusEvent:record.receipt.event}), 'Moritani Cunning has a changed original placement frame.'); const original = before.tokens.find(t => t.id === record.receipt.token)!; const current = g.moritaniTerror?.tokens.find(t => t.id === record.receipt.token); requireRule(!current || current.kind === original.kind, 'Moritani Cunning has changed the original physical token face.'); events.add(record.receipt.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusMoritaniLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}) && (last.stage === 'pending') === !!pending?.nexusEvent, 'Moritani Cunning has lost or reopened its latest placement.'); if (last.stage === 'pending') { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response,karama?.use.kind === 'cancel' ? karama.use.response : null]; }).filter(r => r?.kind === 'moritaniPlacement'); requireRule(pending && pending.nexusEvent === last.receipt.event && pending.token === last.receipt.token && pending.territory === last.receipt.territory && pending.turn === last.receipt.turn && g.turn === last.receipt.turn && g.phase === 8 && JSON.stringify(pending) === last.frame && JSON.stringify(g.moritaniTerror) === last.before && responses.length > 0 && responses.every(r => r!.owner === last.receipt.owner) && g.nexusCards.cards.discard.includes('moritani'), 'Moritani Cunning no longer matches its original token, target or response.'); } } if (g.nexusMoritaniLocations !== undefined) { requireRule(g.nexusMoritaniLocations && typeof g.nexusMoritaniLocations === 'object' && !Array.isArray(g.nexusMoritaniLocations), 'Moritani Cunning has an invalid live placement map.'); for (const [id,placement] of Object.entries(g.nexusMoritaniLocations)) { const token = g.moritaniTerror?.tokens.find(t => t.id === id); requireRule(placement && Object.keys(placement).sort().join(',') === 'event,territory' && token?.status === 'placed' && token.location === placement.territory && terrorLocationAllowed(g,id,placement.territory,placement.event), 'Moritani Cunning has a stale or changed live placement.'); } } for (const token of g.moritaniTerror?.tokens ?? []) { if (token.status === 'placed') requireRule(token.location && terrorLocationAllowed(g,token.id,token.location), 'The Terror token has no valid original placement for this territory.'); } const continuation = g.pendingTreacheryDiscard?.continuation; for (const entry of [g.pendingTerrorEntry, continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' ? continuation.entry : null]) if (entry) requireRule(terrorEntryLocationAllowed(g,entry), 'The Terror arrival has lost its original Cunning placement permission.'); } function recordNexusMoritani(g: Game, owner: string, token: string, to: string) { const receipt = nexusRule(() => createNexusMoritani(g,owner,token,to)); g.pendingMoritaniPlacement = {token,territory:to,turn:g.turn,nexusEvent:receipt.event}; const record = {receipt,stage:'pending' as const,before:JSON.stringify(g.moritaniTerror),frame:JSON.stringify(g.pendingMoritaniPlacement),signature:''}; record.signature = nexusMoritaniRecordSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players)); (g.nexusMoritaniHistory ??= []).push(record); g.nexusMoritaniLast = {event:receipt.event,stage:record.stage}; } function finishNexusMoritani(g: Game, canceled: boolean) { const pending = g.pendingMoritaniPlacement; if (!pending?.nexusEvent) return; const record = g.nexusMoritaniHistory?.find(r => r.receipt.event === pending.nexusEvent); requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(pending) && record.before === JSON.stringify(g.moritaniTerror), 'Moritani Cunning has lost its original placement outcome.'); record.stage = canceled ? 'canceled' : 'complete'; record.signature = nexusMoritaniRecordSignature(record); g.nexusMoritaniLast = {event:record.receipt.event,stage:record.stage}; } function nexusChoamSignature(record: NonNullable[number]) { return JSON.stringify([record.receipt.signature, record.stage, record.frame, record.parent]); } function nexusChoamParent(g: Game, pending: NonNullable) { return JSON.stringify({ movement: pending.movement ? g.pendingChoamMove : undefined, revival: pending.revival ? g.pendingRevival : undefined, storm: pending.storm ? g.stormResolution : undefined, mentat: pending.mentat ? g.choamMentatPending : undefined, }); } function nexusChoamIntegrity(g: Game) { const pending = g.pendingChoamWorthless; const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; const responses = contexts.flatMap(context => { const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null]; }).filter(response => response?.kind === 'choamWorthless'); const history = g.nexusChoamHistory; if (history === undefined) { requireRule(!g.nexusChoamLast && !pending?.nexusEvent, 'CHOAM Cunning has lost its original saved history.'); return; } requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, 'CHOAM Cunning history requires its original Nexus module.'); const events = new Set(); for (const [index, record] of history.entries()) { requireRule(record && typeof record === 'object' && Object.keys(record).sort().join(',') === 'frame,parent,receipt,signature,stage' && ['pending','canceled','complete','fizzled'].includes(record.stage) && typeof record.frame === 'string' && typeof record.parent === 'string' && record.signature === nexusChoamSignature(record) && !events.has(record.receipt?.event) && (record.stage !== 'pending' || index === history.length - 1), 'CHOAM Cunning has a changed or duplicated outcome record.'); nexusRule(() => validateNexusChoam(g,record.receipt)); events.add(record.receipt.event); } const last = history.at(-1)!; requireRule(JSON.stringify(g.nexusChoamLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}), 'CHOAM Cunning has lost its latest completed or pending outcome.'); requireRule((last.stage === 'pending') === !!pending?.nexusEvent, 'CHOAM Cunning has lost or reopened its declared effect.'); if (last.stage === 'pending') { requireRule(pending && pending.nexusEvent === last.receipt.event && pending.owner === last.receipt.owner && pending.card === last.receipt.card && pending.effect === last.receipt.effect && g.turn === last.receipt.turn && g.phase === last.receipt.phase && JSON.stringify(pending) === last.frame && nexusChoamParent(g,pending) === last.parent && responses.length > 0 && g.nexusCards.cards.discard.includes('choam'), 'CHOAM Cunning no longer matches its original card, selected effect or response.'); for (const response of responses) { try { quoteChoamWorthlessCancellation(g,response!); } catch(error) { if (error instanceof ChoamWorthlessCancellationError) throw new RuleError(error.message); throw error; } } } } function recordNexusChoam(g: Game, p: Player, card: Card, effect: NexusChoamEffect) { const receipt = nexusRule(() => createNexusChoam(g,p.id,g.phase,card.id,effect)); const record = {receipt,stage:'pending' as const,frame:JSON.stringify(g.pendingChoamWorthless),parent:nexusChoamParent(g,g.pendingChoamWorthless!),signature:''}; record.signature = nexusChoamSignature(record); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); (g.nexusChoamHistory ??= []).push(record); g.nexusChoamLast = {event:receipt.event,stage:record.stage}; } function finishNexusChoam(g: Game, pending: NonNullable, stage: 'canceled' | 'complete' | 'fizzled') { if (!pending.nexusEvent) return; const record = g.nexusChoamHistory?.find(record => record.receipt.event === pending.nexusEvent); requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(pending) && record.parent === nexusChoamParent(g,pending), 'CHOAM Cunning has lost its original declared effect.'); record.stage = stage; record.signature = nexusChoamSignature(record); g.nexusChoamLast = {event:record.receipt.event,stage}; } function choamPowerPlays(g: Game, p: Player) { const printed = p.hand.filter(card => card.kind === 'worthless' && Object.values(CHOAM_NEXUS_EFFECTS).includes(card.name as typeof CHOAM_NEXUS_EFFECTS[NexusChoamEffect]) && !choamWorthlessBlocked(g,card.id)) .map(card => ({source:'printed' as const,card,effect:Object.entries(CHOAM_NEXUS_EFFECTS).find(([,name]) => name === card.name)![0] as NexusChoamEffect, blocked:card.name === 'Kull Wahad' ? 'Kull Wahad’s reaction and discard sequence is still being implemented.' : null})); if (p.faction !== 'choam' || p.ally || g.nexusCards?.cards?.hands[p.id] !== 'choam') return printed; return [...printed, ...p.hand.flatMap(card => (Object.keys(CHOAM_NEXUS_EFFECTS) as NexusChoamEffect[]).map(effect => { let blocked: string | null = null; if (effect === 'kull') blocked = 'Kull Wahad’s reaction and discard sequence is still being implemented.'; else if (g.status !== 'playing' || g.truthtrance || g.response || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard || g.choamMarket) blocked = 'Finish the current interaction before declaring CHOAM Cunning.'; else if (choamWorthlessBlocked(g,card.id)) blocked = 'This card’s special-effect use is blocked for this phase.'; else if (giftReserved(g,p.id,card.id)) blocked = 'This physical card is reserved for a pending gift.'; else if (({kulon:5,laLaLa:4,gamont:8,baliset:5,jubba:0} as const)[effect] !== g.phase) blocked = 'Use this special effect in its normal phase.'; else if (g.decision && !(g.decision.player === p.id && ['choamStorm','choamFreeRevival','choamMovement','choamMentat'].includes(g.decision.kind))) blocked = 'Finish the current decision before declaring this effect.'; else if (effect === 'kulon' && (g.active !== p.id || p.moved >= movesAllowed(g,p))) blocked = 'Use Kulon before an available move on your own turn.'; else if (effect === 'kulon' && g.ornithopter?.mode === 'range3' && g.ornithopter.player === p.id) blocked = 'Kulon combined with fixed Ornithopter range awaits a ruling.'; else if (effect === 'jubba' && !(g.decision?.kind === 'choamStorm' && g.decision.player === p.id)) blocked = 'Use Jubba Cloak in your moving-storm response.'; return {source:'nexus' as const,card,effect,event:JSON.stringify(['nexusChoam',g.turn,g.phase,p.id,card.id,effect]),blocked}; }))]; } function gamontAvailable( g: Game, target: string, key: string, elite: number, noFieldEvent?: string, ) { const p = g.players.find((p) => p.id === target); if (!p || !key) return false; if (noFieldEvent) { const marker = p.noField?.deployed; return ( !!marker && p.noFieldEvent === noFieldEvent && location(marker.location.territory, marker.location.sector) === key && elite === 0 ); } if ( p.noField?.deployed && location( p.noField.deployed.location.territory, p.noField.deployed.location.sector, ) === key ) return false; const n = p.forces[key] ?? 0, e = p.elites?.forces[key] ?? 0; return elite === 1 ? e > 0 : elite === 0 && n > e; } function playChoamWorthless(g: Game, p: Player, action: Action) { const storming = g.decision?.kind === 'choamStorm' && g.decision.player === p.id; const reactive = g.decision?.kind === 'choamFreeRevival' && g.decision.player === p.id; const moving = g.decision?.kind === 'choamMovement' && g.decision.player === p.id; const mentat = g.decision?.kind === 'choamMentat' && g.decision.player === p.id; requireRule( g.status === 'playing' && p.faction === 'choam' && !g.response && !g.choamMarket && (!g.decision || reactive || mentat || moving || storming) && (!g.pendingRevival || reactive), 'CHOAM must finish the current decision before playing this Worthless effect.', ); const nexus = action.nexus !== undefined; const card = p.hand.find((c) => c.id === action.card && (nexus || c.kind === 'worthless')); requireRule( card && !choamWorthlessBlocked(g, card.id), 'Choose an owned Worthless card whose effect is not blocked this phase.', ); let effectName = card.name; if (nexus) { requireRule(Object.keys(action).every(key => ['type','mode','card','nexus','effect','target','territory','amount','from','elite'].includes(key)), 'Choose one physical card, special effect and its current target.'); const play = choamPowerPlays(g,p).find(play => play.source === 'nexus' && play.card.id === card.id && play.effect === action.effect && 'event' in play && play.event === action.nexus); requireRule(play && !play.blocked, play?.blocked ?? 'Choose your current CHOAM Nexus Cunning effect.'); effectName = CHOAM_NEXUS_EFFECTS[play.effect]; } requireRule(!giftReserved(g,p.id,card.id), 'This physical card is reserved for a pending gift.'); let effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba'; let target: string | undefined; let key: string | undefined; let elite: number | undefined; let noFieldEvent: string | undefined; if (effectName === 'Kulon') { requireRule( g.ornithopter?.mode !== 'range3' || g.ornithopter.player !== p.id, 'Kulon combined with fixed Ornithopter range awaits a ruling.', ); requireRule( g.phase === 5 && g.active === p.id && p.moved < movesAllowed(g, p), 'Use Kulon on your movement turn before an available move.', ); effect = 'kulon'; } else if (effectName === 'Jubba Cloak') { requireRule( g.phase === 0 && storming && g.stormResolution, 'Use Jubba Cloak when CHOAM responds to the moving storm.', ); key = stringField(action.territory); requireRule( choamStormOptions(g).some((t) => t.territory === key), 'Choose an unprotected territory where the moving storm threatens CHOAM forces.', ); requireRule( action.target === undefined || action.target === p.id, 'Jubba Cloak protects only CHOAM forces.', ); target = p.id; effect = 'jubba'; } else if (effectName === 'Baliset') { requireRule( g.phase === 5, 'Baliset is played during Shipment and Movement.', ); target = moving && g.decision?.kind === 'choamMovement' ? g.decision.mover : stringField(action.target); key = moving && g.decision?.kind === 'choamMovement' ? g.decision.territory : stringField(action.territory); requireRule( target !== p.id && g.players.some((other) => other.id === target), 'Choose another player.', ); requireRule( gameTerritories(g).some((t) => t.id === key) && at(p, key) > 0, 'Choose a territory CHOAM occupies.', ); requireRule( !g.choamBaliset?.some( (b) => b.turn === g.turn && b.player === target && b.territory === key, ), 'That player and territory are already restricted.', ); effect = 'baliset'; } else if (effectName === 'Trip to Gamont') { requireRule( g.phase === 8 && (action.amount === undefined || action.amount === 1), 'Trip to Gamont returns exactly one force during Mentat Pause.', ); target = stringField(action.target); const other = getPlayer(g, target); requireRule( other.id !== p.id, 'Choose a force belonging to another player.', ); key = stringField(action.from); const loc = splitLocation(key); const marker = other.noField?.deployed; const concealed = !!marker && location(marker.location.territory, marker.location.sector) === key; requireRule( validGameLocation(g, loc.territory, loc.sector) && ((other.forces[key] ?? 0) > 0 || concealed), 'Choose an occupied board sector.', ); if (concealed) { requireRule( other.faction === 'richese' && !!other.noFieldEvent && (action.elite === undefined || action.elite === 0), 'Choose the Richese No-Field sector and ordinary force type.', ); noFieldEvent = other.noFieldEvent; elite = 0; } else elite = eliteChoice( 1, other.forces[key], other.elites?.forces[key] ?? 0, action.elite, ); effect = 'gamont'; } else { requireRule( effectName === 'La La La' && g.phase === 4, 'This Worthless effect is not available in this phase.', ); effect = 'laLaLa'; target = reactive && g.decision?.kind === 'choamFreeRevival' ? g.decision.recipient : stringField(action.target); getPlayer(g, target); requireRule( !g.revivalRules?.freeBlocked?.includes(target), 'That player is already prevented from free revival.', ); } g.pendingChoamWorthless = { owner: p.id, card: card.id, effect, target, revival: reactive, movement: moving, storm: storming, location: key, elite, mentat, ...(noFieldEvent ? { noFieldEvent } : {}), ...(nexus ? {nexusEvent:action.nexus as string} : {}), }; if (nexus) recordNexusChoam(g,p,card,effect); if (reactive || mentat || moving || storming) g.decision = null; g.ready = []; g.response = { kind: 'choamWorthless', owner: p.id, ...(target ? { recipient: target } : {}), ...(key ? { location: effect === 'jubba' ? location( key, choamStormOptions(g).find((t) => t.territory === key)! .sectors[0], ) : effect === 'baliset' ? location( key, g.pendingChoamMove?.sector ?? territory(key).sectors[0], ) : key, ...(elite !== undefined ? { elite } : {}), } : {}), intent: effectName, passed: [], }; log(g, nexus ? `${p.name} spent CHOAM Nexus Cunning and declared ${effectName}. Its chosen Treachery card stays private and is discarded only if the effect occurs; Karama may prevent this native advantage.` : `${p.name} declared ${effectName} for its special effect.`); } /** Internal normalized command; never accept this object directly from a route. */ export type SpecialKaramaIntent = { owner: string; card: string; turn: number; phase: number; } & ( | { kind: 'choam'; cards: string[] } | { kind: 'richese'; acquire: string } | { kind: 'ixians'; route: string[]; collect: boolean } | { kind: 'tleilaxu'; target: string; revival: PendingRevival } | { kind: 'fremen'; territory: string } | { kind: 'atreides'; target: string; battle: Pick; } | { kind: 'guild'; target: string; shipment: PendingShipment } | { kind: 'guildHomeworld'; target: string; shipment: PendingHomeworldShipment } | { kind: 'emperorForces'; amount: number; elite: number } | { kind: 'emperorLeader'; leader: string } | { kind: 'harkonnen'; target: string; amount: number } ); /** Validates and copies a selection without spending resources, continuing play, or drawing RNG. */ export function prepareSpecialKaramaIntent( g: Game, playerId: string, action: Action, ): SpecialKaramaIntent { const p = getPlayer(g, playerId); // Delayed execution must respect the same enclosing windows as dispatch. // CHOAM's early cash-in bypasses the later gates, but never Truthtrance. requireRule(!g.truthtrance, 'Resolve the current Truthtrance first.'); if (p.faction !== 'choam') { requireRule( !g.phaseOpening, 'Play Amal or pass the phase opening before taking other actions.', ); requireRule( !g.choamMarket, 'Finish the end-of-phase exchange before another special power.', ); requireRule( !g.pendingRevival || (p.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop'), 'Resolve the pending revival first.', ); } requireRule( g.status === 'playing' && g.advanced, 'Special Karama powers require the advanced game.', ); requireRule( !p.specialKaramaUsed, 'Your special Karama power has already been used this game.', ); const card = p.hand.find( (c) => c.id === action.card && canUseAsKaramaRole(g, p, c), ); requireRule(card, 'Choose a Karama card in your hand.'); const base = { owner: p.id, card: card.id, turn: g.turn, phase: g.phase }; if (p.faction === 'richese') { const blocked = karamaSpendingBlock(g, p, card); requireRule(!blocked, blocked ?? 'This Karama is committed elsewhere.'); requireRule( !( g.richeseAuction?.source === 'blackMarket' && !g.currentAuctionSale && g.richeseAuction.owner === p.id && g.richeseAuction.cardId === card.id ), 'The Karama reserved for Black Market cannot activate another effect.', ); requireRule( p.hand.length < handLimit(p), 'Special Richese Karama with a full hand awaits a ruling on acquisition and discard order.', ); requireRule( uncommittedSpice(g, p) >= 3, 'This purchase needs 3 uncommitted spice.', ); requireRule( !g.pendingRichesePurchaseIncome, 'Resolve the previous purchase income first.', ); const acquire = stringField(action.acquire); requireRule(g.pendingIxRicheseTechnology?.card.id !== acquire, 'The card reserved for the pending Richese lot cannot be acquired by another effect.'); const chosen = g.richeseCache?.find((c) => c.id === acquire); requireRule( chosen && richeseCardDefinition(chosen), 'Choose a verified card in your Richese cache.', ); requireRule( g.richeseAuction?.source !== 'cache' || g.currentAuctionSale || g.richeseAuction.cardId !== acquire, 'The card already offered for auction must remain in that auction.', ); requireRule( g.richeseCache!.length > 1, 'Buying the final cache card awaits the empty-cache Bidding rule implementation.', ); return { ...base, kind: 'richese', acquire }; } else if (p.faction === 'choam') { requireRule( Array.isArray(action.cards) && action.cards.length > 0 && action.cards.every((id) => typeof id === 'string'), 'Select cards to discard for spice.', ); const selected = action.cards as string[]; requireRule( new Set(selected).size === selected.length && !selected.includes(card.id), 'Choose distinct cards; the activating Karama is spent separately.', ); const available = cashInCards(g, p); requireRule( [card.id, ...selected].every((id) => available.some((c) => c.id === id)), 'Choose your own uncommitted cards. Sealed and prescience cards must remain available.', ); const removed = new Set([card.id, ...selected]); const credit = g.auction?.allyPayment ?? 0; requireRule( !g.auction || g.auction.bidder !== p.id || g.auction.bid <= p.spice + selected.length * 3 + credit || p.hand.some( (c) => canUseAsKaramaRole(g, p, c) && !removed.has(c.id), ), 'Retain enough spice or Karama to honor your current winning bid.', ); return { ...base, kind: 'choam', cards: [...selected] }; } else if (p.faction === 'ixians') { requireRule( g.phase === 5 && g.active === p.id && !g.response && !g.decision, 'Move the stronghold with special Karama during your own Shipment and Movement turn.', ); const route = validateMobileMove(g, p, action.route, 2); return { ...base, kind: 'ixians', route: [...route], collect: action.collect !== false, }; } else if (p.faction === 'tleilaxu') { requireRule( g.phase === 4 && !g.response && g.decision?.kind === 'revivalStop' && g.decision.player === p.id && g.pendingRevival, 'Wait for a normal revival declaration before preventing it.', ); const target = g.pendingRevival.player; const homeworldBlock = homeworldRevivalKaramaBlock(g, target); requireRule( !homeworldBlock, homeworldBlock ?? '', ); requireRule( action.target === undefined || action.target === target, 'This decision applies to the faction currently reviving.', ); return { ...base, kind: 'tleilaxu', target, revival: structuredClone(g.pendingRevival), }; } else if (p.faction === 'fremen') { requireRule( g.phase === 1 && !g.summonedWorm, 'Summon Shai-Hulud during Spice Blow and Nexus.', ); const t = stringField(action.territory); requireRule( TERRITORIES.some((x) => x.id === t && x.type === 'sand'), 'Summon Shai-Hulud in a sand territory.', ); return { ...base, kind: 'fremen', territory: t }; } else if (p.faction === 'atreides') { const b = g.battle; requireRule( g.phase === 6 && !g.response && g.decision?.kind === 'fullPlanOffer' && g.decision.player === p.id && b && !b.revealed && !b.preparation && !Object.keys(b.plans).length, 'Choose special prescience after battle preparation and before plans are sealed.', ); const target = stringField(action.target); requireRule( [b.attacker, b.defender].includes(target), 'Choose a player in this battle.', ); requireRule( !b.noFieldPlayers?.includes(target), 'Whole-plan special prescience against a No-Field awaits its disclosure ruling. Ordinary permitted elements remain available.', ); return { ...base, kind: 'atreides', target, battle: { territory: b.territory, attacker: b.attacker, defender: b.defender, }, }; } else if (p.faction === 'guild') { if (g.decision?.kind === 'homeworldShipmentGuild') { requireRule(!g.response && g.decision.player === p.id, 'Wait for your Homeworld shipment interception decision.'); homeworldShipmentIntegrity(g); const shipment = g.pendingHomeworldShipment!; requireRule(action.target === undefined || action.target === shipment.player, 'Stop the player who declared this Homeworld shipment.'); return {...base, kind: 'guildHomeworld', target: shipment.player, shipment: structuredClone(shipment)}; } requireRule( g.phase === 5 && !g.response && g.decision?.kind === 'guildShipment' && g.decision.player === p.id && g.pendingShipment, 'Wait for an off-planet shipment declaration.', ); validateGuildShipmentDecision(g, g.decision); const shipper = getPlayer(g, g.pendingShipment.player); return { ...base, kind: 'guild', target: shipper.id, shipment: { ...g.pendingShipment }, }; } else if (p.faction === 'emperor') { requireRule( action.target === undefined || action.target === p.id, 'This revival power applies to the Emperor’s own forces or leaders.', ); requireRule( g.phase === 4, 'The Emperor special Karama is used during Revival.', ); if (action.leader) { requireLeaderSkillRevival(g,p.id); const l = p.leaders.find((l) => l.id === action.leader); requireRule( l?.dead && !l.capturedBy && !l.gholaBy, 'Choose one of your dead leaders.', ); requireRule( action.amount === undefined && action.elite === undefined, 'Choose forces or one leader, not both.', ); return { ...base, kind: 'emperorLeader', leader: l.id }; } const n = integer(action.amount, 1, Math.min(3, p.tanks), 'Forces'); const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite); requireRule( elite <= eliteRevivalRemaining(p, g.advanced), 'Only one elite force may be revived per turn.', ); return { ...base, kind: 'emperorForces', amount: n, elite }; } else if (p.faction === 'harkonnen') { requireRule( g.phase === 3 && !g.pendingExchange, 'The Harkonnen special Karama is used during Bidding.', ); const target = getPlayer(g, stringField(action.target)); requireRule(target.id !== p.id, 'Choose another player’s hand.'); const physical = physicalTreacheryCards(g); requireRule([...p.hand, ...target.hand].every(held => physical.filter(current => current.id === held.id).length === 1), 'The Harkonnen exchange requires distinct physical cards in their original custody.'); requireRule( g.pendingRicheseGift?.intent.owner !== target.id, 'Resolve the reserved Richese gift before randomly exchanging that hand.', ); requireRule( g.pendingIxRicheseTechnology?.source !== 'blackMarket' || g.pendingIxRicheseTechnology.owner !== target.id, 'Resolve the pending Black Market Ixian choice before randomly exchanging that hand.', ); const count = integer( action.amount, 1, Math.min(4, target.hand.length), 'Cards to take', ); requireRule( action.cards === undefined, 'Cards are drawn unseen, not selected by identity.', ); return { ...base, kind: 'harkonnen', target: target.id, amount: count }; } else throw new RuleError( 'This faction’s special Karama power is still being implemented.', ); } // Convert only known normalized fields for revalidation. The original client Action // is never retained, and omitted defaults cannot choose another card or elite mix. function specialKaramaAction(intent: SpecialKaramaIntent): Action { const action = { type: 'card', mode: 'special', card: intent.card }; switch (intent.kind) { case 'richese': return { ...action, acquire: intent.acquire }; case 'choam': return { ...action, cards: [...intent.cards] }; case 'ixians': return { ...action, route: [...intent.route], collect: intent.collect }; case 'tleilaxu': case 'atreides': case 'guild': case 'guildHomeworld': return { ...action, target: intent.target }; case 'fremen': return { ...action, territory: intent.territory }; case 'emperorLeader': return { ...action, leader: intent.leader }; case 'emperorForces': return { ...action, amount: intent.amount, elite: intent.elite }; case 'harkonnen': return { ...action, target: intent.target, amount: intent.amount }; } } /** Revalidates exact custody and the original window before any mutation. * A future paused-intent caller must restore its original decision/response first; * this executor does not open, pass, or recreate a reaction window. * Like other effect executors, it mutates a disposable working Game. The caller * must publish that clone only after success; applyAction provides this boundary. */ export function executeSpecialKaramaIntent( g: Game, intent: SpecialKaramaIntent, ): void { requireRule( g.turn === intent.turn && g.phase === intent.phase, 'This special Karama declaration belongs to an earlier turn or phase.', ); const prepared = prepareSpecialKaramaIntent( g, intent.owner, specialKaramaAction(intent), ); requireRule( prepared.kind === intent.kind, 'This special Karama declaration belongs to a different faction.', ); // Pending declarations contain scalar fields and (for revival) an ordered checks // array. Compare field values independently of JSON object property order. const sameDeclaration = (before: object, now: object) => { const previous = before as Record; const current = now as Record; return [...new Set([...Object.keys(before), ...Object.keys(now)])].every( (key) => JSON.stringify(previous[key]) === JSON.stringify(current[key]), ); }; if (intent.kind === 'tleilaxu' && prepared.kind === 'tleilaxu') requireRule( intent.target === prepared.target && sameDeclaration(intent.revival, prepared.revival), 'The original revival declaration is no longer pending.', ); if (intent.kind === 'guild' && prepared.kind === 'guild') requireRule( intent.target === prepared.target && sameDeclaration(intent.shipment, prepared.shipment), 'The original shipment declaration is no longer pending.', ); if (intent.kind === 'guildHomeworld' && prepared.kind === 'guildHomeworld') requireRule(intent.target === prepared.target && sameDeclaration(intent.shipment, prepared.shipment), 'The original Homeworld shipment declaration is no longer pending.'); if (intent.kind === 'atreides' && prepared.kind === 'atreides') requireRule( sameDeclaration(intent.battle, prepared.battle), 'The original battle is no longer awaiting special prescience.', ); const p = getPlayer(g, intent.owner); const card = p.hand.find((candidate) => candidate.id === intent.card)!; const converted = card.effect === 'truthtrance'; if (converted) log( g, `${p.name} used the physical Truthtrance as Karama for their special faction power.`, ); if (intent.kind === 'richese') { const selected = g.richeseCache!.findIndex((c) => c.id === intent.acquire); discard(g, p, card.id); p.spice -= 3; p.hand.push(...g.richeseCache!.splice(selected, 1)); p.specialKaramaUsed = true; log( g, `${p.name} spent Karama and 3 spice to buy one secretly chosen card from the Richese cache. This once-per-game special power cannot be canceled.`, { faction: 'richese', name: 'Special Karama purchase' }, ); const emperor = byFaction(g, 'emperor'); if (emperor) { g.pendingRichesePurchaseIncome = { owner: emperor.id, turn: g.turn, phase: g.phase, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama ?? null, }, }; g.decision = null; g.pendingKarama = null; g.response = { kind: 'richesePurchaseIncome', owner: emperor.id, amount: 3, passed: [], }; } } else if (intent.kind === 'choam') { const selected = intent.cards; discard(g, p, card.id); for (const id of selected) discard(g, p, id); p.spice += selected.length * 3; p.specialKaramaUsed = true; log( g, `${p.name} used special Karama to discard ${selected.length} cards and gain ${selected.length * 3} spice.`, ); } else if (intent.kind === 'ixians') { const route = intent.route; discard(g, p, card.id); p.specialKaramaUsed = true; relocateMobileStronghold(g, { player: p.id, route, collect: intent.collect, }); } else if (intent.kind === 'tleilaxu') { const target = intent.target; discard(g, p, card.id); p.specialKaramaUsed = true; g.revivalPrevention = { player: target, turn: g.turn }; g.pendingRevival = null; g.decision = null; if (g.revivalRequests) delete g.revivalRequests[target]; log( g, `${p.name} used special Karama to prevent ${getPlayer(g, target).name}’s normal force and leader revivals for this turn.`, ); } else if (intent.kind === 'fremen') { const t = intent.territory; discard(g, p, card.id); p.specialKaramaUsed = true; g.summonedWorm = { territory: t, event: crypto.randomUUID(), resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, spiceWindow: g.spiceWindow, spiceResolution: g.spiceResolution, spiceSequence: g.spiceSequence, nexus: g.nexus, wormRides: g.wormRides, ready: g.ready, }, }; g.response = null; g.decision = null; g.pendingKarama = null; g.spiceWindow = null; g.spiceResolution = null; g.spiceSequence = null; g.wormRides = []; g.ready = []; g.nexus = true; log( g, `${p.name} used special Karama to summon Shai-Hulud in ${territory(t).name}.`, ); beginWorm(g, t, 'summoned'); } else if (intent.kind === 'atreides') { const b = g.battle!, target = intent.target; discard(g, p, card.id); p.specialKaramaUsed = true; b.fullPlan = { owner: p.id, target }; g.decision = null; log( g, `${p.name} used special Karama to inspect ${getPlayer(g, target).name}’s entire battle plan.`, ); } else if (intent.kind === 'guildHomeworld') { const shipper = getPlayer(g, intent.target); discard(g, p, card.id); p.specialKaramaUsed = true; if (g.pendingHomeworldShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'homeworld',g.pendingHomeworldShipment,'stopped'); shipper.shipped = true; g.pendingHomeworldShipment = null; g.decision = null; finishShipmentPromises(g, shipper, null); log(g, `${p.name} used special Karama to stop ${shipper.name}’s interplanetary shipment. No forces arrive or spice is paid; their shipment is used and movement remains available.`); } else if (intent.kind === 'guild') { const shipper = getPlayer(g, intent.target); discard(g, p, card.id); p.specialKaramaUsed = true; if (g.pendingShipment?.source === 'ambassador') { currentGuildAmbassador(g, g.pendingShipment.ambassadorEvent); g.pendingShipment = null; g.decision = null; log( g, `${p.name} used special Karama to stop ${shipper.name}’s Guild Ambassador shipment. No forces arrive; ordinary shipment and movement allowances are unchanged.`, ); finishAmbassador(g); return; } if (g.pendingShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'reserve',g.pendingShipment,'stopped'); if (g.pendingShipment) finishNexusRicheseShipment(g,g.pendingShipment,'stopped'); if (g.pendingShipment) finishRichesePairShipment(g, g.pendingShipment, 'stopped'); shipper.shipped = true; // Provisional canceled-shipment settlement pending a primary-source // clarification: retain the attempted shipment's spice and rate card. const benefit = g.karamaShipping; if (benefit?.player === shipper.id && benefit.card) { const index = g.discard.findIndex((c) => c.id === benefit.card); if (index >= 0) getPlayer(g, benefit.owner).hand.push(...g.discard.splice(index, 1)); } g.karamaShipping = null; g.pendingShipment = null; g.decision = null; log( g, `${p.name} used special Karama to stop ${shipper.name}’s shipment. No forces arrive or spice is paid; movement remains available.`, ); } else if ( intent.kind === 'emperorLeader' || intent.kind === 'emperorForces' ) { if (intent.kind === 'emperorLeader') { const l = p.leaders.find((l) => l.id === intent.leader)!; l.dead = false; delete l.concealed; log(g, `${p.name} used special Karama to revive ${l.name} for free.`); } else { const n = intent.amount, elite = intent.elite; addRevivedReserves(g, p, n, elite); p.tanks -= n; if (p.elites) { p.elites.tanks -= elite; p.elites.revived += elite; } log(g, `${p.name} used special Karama to revive ${n} forces for free.`); } techIncome(g, 'axlotl', p); discard(g, p, card.id); p.specialKaramaUsed = true; } else if (intent.kind === 'harkonnen') { const target = getPlayer(g, intent.target), count = intent.amount; discard(g, p, card.id); p.specialKaramaUsed = true; const taken = shuffle(target.hand).slice(0, count); target.hand = target.hand.filter((c) => !taken.some((t) => t.id === c.id)); p.hand.push(...taken); recordHarkonnenInspection(g, p.id, target.id, 'draw', taken); g.pendingExchange = { response: g.response, decision: g.decision }; g.response = null; g.decision = { kind: 'handExchange', player: p.id, target: target.id, count, }; log( g, `${p.name} used special Karama to take ${count} unseen cards from ${target.name}; an equal return is pending.`, ); } } function specialKarama(g: Game, p: Player, action: Action) { executeSpecialKaramaIntent(g, prepareSpecialKaramaIntent(g, p.id, action)); } /** Private, read-only choices; validation performs no draws or continuations. */ function richeseSpecialKaramaView(g: Game, p: Player) { if ( g.status !== 'playing' || !g.advanced || p.faction !== 'richese' || p.specialKaramaUsed ) return null; const karamas = p.hand.filter( (c) => canUseAsKaramaRole(g, p, c) && !karamaSpendingBlock(g, p, c) && !( g.richeseAuction?.source === 'blackMarket' && !g.currentAuctionSale && g.richeseAuction.owner === p.id && g.richeseAuction.cardId === c.id ), ); const cache = (g.richeseCache ?? []).filter((c) => richeseCardDefinition(c)); const cards: Card[] = []; let blocked: string | null = !karamas.length ? 'A spendable Karama card is required.' : !cache.length ? 'There are no verified cards in your Richese cache.' : null; if (karamas.length) for (const candidate of cache) { try { prepareSpecialKaramaIntent(g, p.id, { type: 'card', mode: 'special', card: karamas[0].id, acquire: candidate.id, }); cards.push(candidate); } catch (error) { if (!(error instanceof RuleError)) throw error; blocked ??= error.message; } } return { cards, karamas, blocked: cards.length ? null : blocked, payee: byFaction(g, 'emperor')?.id ?? null, }; } type CompletedMovement = Pick< MovementOrder, 'player' | 'origin' | 'origins' | 'total' | 'to' | 'sector' | 'elite' > & { noField: boolean }; type MovementOrder = { sandmaster?: SandmasterMovement; source?: 'ambassador'; ambassadorEvent?: string; player: string; group: [string, number][]; eliteGroup: Record; elite: number; origin: string; origins?: string[]; planetologist?: PlanetologistMovement; total: number; to: string; sector: number; advisors: boolean; wantsFighters: boolean; lockedTurn?: number; noField?: { tokenId: string; event: string; from: string }; ornithopterEvent?: string; ornithopterRange?: boolean; discoveryFlight?: DiscoveryFlightReceipt; }; export type Action = { type: string; [key: string]: unknown }; function normalizeCardNames(g: Game) { normalizeLegacyCardNames([ ...g.deck, ...g.discard, ...(g.ornithopter ? [g.ornithopter.card] : []), ...g.players.flatMap((p) => p.hand), ...(g.auction?.cards ?? []), ...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []), ...(g.ixAuctionKnown?.cards ?? []), ]); } export function applyAction(state: Game, id: string, action: Action): Game { advancedPreviewIntegrity(state); harkonnenExchangeIntegrity(state); validateEcazLoyalty(state); ecazTreacheryIntegrity(state); ixRicheseTechnologyIntegrity(state); leaderSkillsIntegrity(state); moritaniExtortionIntegrity(state); guildRateIntegrity(state); arrivalOverlapIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); nexusChoamInspectionIntegrity(state); nexusMoritaniRetentionIntegrity(state); nexusChoamBetrayalIntegrity(state); nexusEcazInquiryIntegrity(state); nexusEcazDukeIntegrity(state); richesePairIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); homeworldSubstitutionIntegrity(state); homeworldDefenseIntegrity(state); homeworldShipmentIntegrity(state); karamaConversionIntegrity(state); treacheryDiscardIntegrity(state); shipmentPromiseIntegrity(state); saphoMovementIntegrity(state); ambassadorRelocationIntegrity(state); ecazCollectionIntegrity(state); ecazAllianceIntegrity(state); // Seat control is independent of gameplay locks and must preserve their exact continuation. if (action?.type === 'setAutopilot') { requireRule( state.status === 'setup' || state.status === 'playing', 'AI control is available after the game starts and before it ends.', ); const player = getPlayer(state, id); requireRule(!player.bot, 'This is a permanent AI seat.'); requireRule( Object.keys(action).every( (key) => key === 'type' || key === 'difficulty', ), 'You may change AI control only for your own seat.', ); requireRule( action.difficulty === null || (typeof action.difficulty === 'string' && ['Easy', 'Medium', 'Hard', 'Brutal'].includes(action.difficulty)), 'Choose an AI difficulty or take back control.', ); const g = structuredClone(state); const owner = getPlayer(g, id); if ((owner.autopilot ?? null) === action.difficulty) return g; if (action.difficulty === null) { delete owner.autopilot; log(g, `${owner.name} took back control from AI.`); } else { owner.autopilot = action.difficulty as NonNullable; log( g, `${owner.name} enabled ${owner.autopilot} AI control for their seat.`, ); } return g; } if (state.pendingTreacheryDiscard) { getPlayer(state, id); if (state.pendingTreacheryDiscard.reaction && ['semutaPass', 'semutaCommit', 'semutaSelect'].includes(action?.type)) { const g = structuredClone(state); applySemutaReaction(g, id, action); treacheryDiscardIntegrity(g); return g.pendingTreacheryDiscard?.reaction ? g : normalizeAutomaticGame(g); } requireRule( action?.type === 'advanceBots', 'Finish the committed discard continuation before another game action.', ); return normalizeAutomaticGame(state); } const g = applyActionInner(state, id, action); finishLeaderSkillCustody(g, state); observeOccupation(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); karamaConversionIntegrity(g); ornithopterIntegrity(g); lateDefenseIntegrity(g); battleSpecialWeaponIntegrity(g); saphoAggressorIntegrity(g); strongholdIntegrity(g); auditorIntegrity(g); if (g.pendingNullentropy) { const invalid = nullentropyIntegrity(g); requireRule(!invalid, invalid ?? 'The search cannot continue.'); return g; } if ( g.pendingRicheseGift && state.pendingRicheseGift?.event === g.pendingRicheseGift.event ) { const pending = g.pendingRicheseGift; requireRule( getPlayer(g, pending.intent.owner).hand.filter( (c) => c.id === pending.intent.cardId, ).length === 1, 'The card reserved for a pending Richese gift must remain in its owner’s hand.', ); } // An accepted exchange obliges its beneficiary to complete the discard. // Interrupting voluntary card plays must leave a legal completion available. const pending = g.pendingAmbassador; if ( pending && pending.event === state.pendingAmbassador?.event && pending.beneficiary === id ) { const beneficiary = getPlayer(g, id), entrant = getPlayer(g, pending.entrant); if (pending.stage === 'cards' && pending.effect === 'ixians') requireRule( !ambassadorEffectBlock(g, 'ixians', beneficiary, entrant), 'Complete the Ixian Ambassador exchange before spending your last available card.', ); if (pending.stage === 'copy') requireRule( pending.copyChoices.some( (effect) => !ambassadorEffectBlock(g, effect, beneficiary, entrant), ), 'Leave an available effect for the committed Bene Gesserit Ambassador.', ); } const lot = g.richeseAuction; if (lot && !g.currentAuctionSale) { requireRule( lot.source !== 'blackMarket' || !g.truthtrance?.queue.some( (entry) => entry.player === lot.owner && entry.card === lot.cardId, ), 'The card reserved for this Black Market auction cannot also be queued for Truthtrance.', ); requireRule( richeseLotCard(g), 'The card reserved for this auction must remain available.', ); for (const bidder of g.players) { requireRule( richeseOwnCommitment(lot, g.richeseFunding ?? {}, bidder.id) <= bidder.spice, 'Spice committed to an auction cannot be spent again.', ); requireRule( richeseAllyCommitment(lot, g.richeseFunding ?? {}, bidder.id) <= (g.aid[bidder.id]?.amount ?? 0), 'Ally spice committed to an auction cannot be withdrawn.', ); const hasWinningCommitment = lot.outcome ? lot.outcome.kind === 'sold' && lot.outcome.winner === bidder.id : lot.method === 'silent' ? (lot.sealed[bidder.id] ?? 0) > 0 : lot.bidder === bidder.id; if (hasWinningCommitment) requireRule( bidder.hand.length < handLimit(bidder), 'Leave room for the card in your committed auction.', ); } } finishActionContinuations(g); if (g.pendingTreacheryDiscard?.reaction) { treacheryDiscardIntegrity(g); return g; } harkonnenExchangeIntegrity(g); finishLeaderSkillCustody(g, state); leaderSkillsIntegrity(g); reconcileBattlePromises(g, { actor: id, action }); reconcileShipmentPromises(g, { actor: id, action }); settleAutomaticContinuations(g); observeOccupation(g); discoveryIntegrity(g); greatMakerIntegrity(g); nexusChoamTradeIntegrity(g); nexusChoamInspectionIntegrity(g); nexusMoritaniRetentionIntegrity(g); nexusChoamBetrayalIntegrity(g); nexusEcazInquiryIntegrity(g); nexusEcazDukeIntegrity(g); richesePairIntegrity(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); ixRicheseTechnologyIntegrity(g); harkonnenExchangeIntegrity(g); moritaniExtortionIntegrity(g); guildRateIntegrity(g); arrivalOverlapIntegrity(g); return g; } function finishActionContinuations(g: Game) { finishLeaderSkillCustody(g); finishMandatorySkillVisibility(g); if (g.pendingNullentropy) return; // Losing cleanup may finish casualties and produce the winner's mandatory // discard. Retire that next physical batch before exposing optional choices. for (let i = 0; g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction && i < 16; i++) finishTreacheryDiscard(g); if (g.pendingTreacheryDiscard?.reaction) return; requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.'); if (pendingNexusTraitors(g)) return; observeOccupation(g); resumeHomeworldRevivalReturn(g); resumeHomeworldVictoryReturn(g); resumeMarketGhola(g); resumeGrummanCollection(g); if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g); if (g.status === 'playing' && g.leaderSkills && !g.decision && !g.response && !g.truthtrance && !g.phaseOpening) { const owner = g.order.find((id) => g.leaderSkills!.offers[id]); if (owner) g.decision = {kind:'leaderSkillRevival',player:owner,event:g.leaderSkills.offers[owner].event}; } if ( !g.truthtrance && g.pendingTerrorEntry?.stage === 'discard' && g.decision?.kind === 'moritaniTerror' ) { const moritani = getPlayer(g, g.decision.player); if (moritani.hand.length <= handLimit(moritani)) { g.decision = null; finishTerrorEntry(g); } } if ( g.status === 'setup' && !g.truthtrance && !g.decision && !g.response && otherSetupComplete(g) ) { const moritani = byFaction(g, 'moritani'); if (moritani?.reserves === 20) { g.moritaniTerror ??= createTerrorState(random); g.decision = { kind: 'moritaniSetup', player: moritani.id }; } } if ( g.status === 'setup' && !g.truthtrance && !g.response && !g.decision && setupComplete(g) ) finishSetup(g); settleAdvisors(g); if (g.status === 'playing' && g.phase === 8 && g.moritaniExtortion?.stage === 'reserved' && g.moritaniTerror?.placementTurn === g.turn && !g.decision && !g.response && !g.truthtrance && !g.phaseOpening && !g.pendingKarama && !g.pendingTerrorEntry && !g.pendingAmbassador) finishMoritaniPlacement(g); } function settleAutomaticContinuations(g: Game) { if (pendingNexusTraitors(g)) return; if (g.pendingNullentropy) return; for (let iteration = 0; iteration < 128; iteration++) { if (g.truthtrance || g.phaseOpening || g.status === 'finished') return; const response = g.response; const before = JSON.stringify(g); if (response) { if ( !( response.kind === 'harkonnenBonus' && getPlayer(g, response.owner).hand.length >= handLimit(getPlayer(g, response.owner)) ) && g.players.some( (p) => !response.passed.includes(p.id) && (emperorBetrayalWindow(g) || bgBetrayalWindow(g) || responseCancelCards(g, p, response).length > 0), ) ) return; finishResponse(g, false); } else if (!finishAutomaticDecision(g)) return; finishActionContinuations(g); // Opposing/automatic consequences can release a now-impossible promise; // they are not the original actor voluntarily spending a promised resource. reconcileBattlePromises(g); reconcileShipmentPromises(g); requireRule( JSON.stringify(g) !== before, 'Automatic response continuation made no progress.', ); } throw new RuleError( 'Automatic response continuation exceeded its bounded limit.', ); } /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { advancedPreviewIntegrity(state); harkonnenExchangeIntegrity(state); validateEcazLoyalty(state); ecazTreacheryIntegrity(state); ixRicheseTechnologyIntegrity(state); leaderSkillsIntegrity(state); moritaniExtortionIntegrity(state); guildRateIntegrity(state); arrivalOverlapIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); nexusChoamInspectionIntegrity(state); nexusMoritaniRetentionIntegrity(state); nexusChoamBetrayalIntegrity(state); nexusEcazInquiryIntegrity(state); nexusEcazDukeIntegrity(state); richesePairIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); homeworldSubstitutionIntegrity(state); homeworldDefenseIntegrity(state); homeworldShipmentIntegrity(state); karamaConversionIntegrity(state); treacheryDiscardIntegrity(state); shipmentPromiseIntegrity(state); saphoMovementIntegrity(state); ambassadorRelocationIntegrity(state); ecazCollectionIntegrity(state); ecazAllianceIntegrity(state); const g = structuredClone(state); ornithopterIntegrity(g); lateDefenseIntegrity(g); battleSpecialWeaponIntegrity(g); strongholdIntegrity(g); auditorIntegrity(g); if (g.pendingNullentropy) return g; normalizeCardNames(g); normalizeBattle(g); finishActionContinuations(g); if (g.pendingTreacheryDiscard?.reaction) { treacheryDiscardIntegrity(g); return g; } reconcileBattlePromises(g); reconcileShipmentPromises(g); settleAutomaticContinuations(g); observeOccupation(g); discoveryIntegrity(g); greatMakerIntegrity(g); nexusChoamTradeIntegrity(g); nexusChoamInspectionIntegrity(g); nexusMoritaniRetentionIntegrity(g); nexusChoamBetrayalIntegrity(g); nexusEcazInquiryIntegrity(g); nexusEcazDukeIntegrity(g); richesePairIntegrity(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); ixRicheseTechnologyIntegrity(g); harkonnenExchangeIntegrity(g); moritaniExtortionIntegrity(g); guildRateIntegrity(g); arrivalOverlapIntegrity(g); return g; } function applyActionInner( state: Game, id: string, action: Action, execution: 'live' | 'shipmentPreparation' = 'live', ): Game { ecazTreacheryIntegrity(state); const g = structuredClone(state); if (g.biddingEnd && action.type !== 'advanceBots' && !(action.type === 'biddingEnd' && action.mode === 'ready')) g.biddingEnd.ready = []; ornithopterIntegrity(g); lateDefenseIntegrity(g); battleSpecialWeaponIntegrity(g); strongholdIntegrity(g); auditorIntegrity(g); if (g.pendingNullentropy && !['nexusTraitorDraw', 'nexusTraitorReturn', 'nexusFaceDancers'].includes(action?.type)) { if (action?.type === 'advanceBots') return g; requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing the paid search.'); requireRule( action?.type === 'decision', 'Finish the paid Nullentropy Box search before another game action.', ); finishNullentropy( g, id, stringField(action.event), stringField(action.card), ); return g; } if (!g.playerPositions) { g.playerPositions = normalizedPlayerPositions(g); if (g.status === 'lobby') g.storm = STORM_START_SECTOR; if (g.status !== 'lobby') log( g, 'Assigned printed player circles in original seating order. Current turn order and storm choices are unchanged; the next completed storm uses these circles.', ); } normalizeCardNames(g); // Older persisted rooms predate decision windows. g.decision ??= null; g.pendingCapture ??= null; g.response ??= null; g.stormPending ??= null; g.stormCard ??= null; g.stormCardKnown ??= false; g.stormResolution ??= null; g.karamaShipping ??= null; g.wormRides ??= []; g.wormPlacementCanceledTurn ??= 0; g.spiceResolution ??= null; g.spiceSequence ??= null; g.spiceWindow ??= null; g.spicePeekKnown ??= false; g.freeRevival ??= []; g.emperorExtra ??= {}; g.aid ??= {}; g.movementRemaining ??= null; g.guildTimingGranted ??= false; g.guildTimingLocked ??= false; normalizeBattle(g); const p = getPlayer(g, id); requireRule( action && typeof action.type === 'string', 'Action type is required.', ); requireRule(g.status !== 'finished', 'This game has ended.'); const t = action.type; if (t === 'advanceBots') return g; requireRule(!g.bureaucratPayments?.pending || t === 'decision','Resolve the pending Bureaucrat payment before another action.'); requireRule(!(g.battle?.mentatQuestion?.stage === 'name' || g.battle?.mentatQuestion?.stage === 'reveal') || t === 'decision', 'Resolve the current Mentat question before another action.'); if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; } requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.'); if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; } if (t === 'nexusFaceDancers') { playNexusFaceDancers(g, p, action); return g; } if (t === 'nexusSuboids') { playNexusSuboids(g, p, action); return g; } if (t === 'nexusAdvisors') { playNexusAdvisors(g, p, action); return g; } if (t === 'nexusSardaukar') { playNexusSardaukar(g,p,action); return g; } if (t === 'nexusTraitorReturn') { requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.'); finishNexusTraitorReturn(g, p, action); return g; } if (t === 'discovery') { playDiscovery(g, p, action); return g; } if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; } if (t === 'nexusChoamBetrayal') { playNexusChoamBetrayal(g, p, action); return g; } if (t === 'nexusEcazInquiry') { playNexusEcazInquiry(g, p, action); return g; } if (t === 'nexusEcazDuke') { playNexusEcazDuke(g, p, action); return g; } if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; } if (t === 'nexusEmperorPurchase') { playNexusEmperorPurchase(g,p,action); return g; } if (t === 'nexusEmperorBetrayal') { playNexusEmperorBetrayal(g,p,action); return g; } if (t === 'nexusBgBetrayal') { playNexusBgBetrayal(g,p,action); return g; } if (t === 'nexusFremenRevive') { playNexusFremenRevive(g,p,action); return g; } if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; } if (t === 'nexusEcazBetrayal') { playEcazBetrayal(g, p, action); return g; } if (t === 'nexusFremenBetrayal') { playNexusFremenBetrayal(g, p, action); return g; } if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; } requireRule( !( t === 'card' && g.richeseAuction?.source === 'blackMarket' && !g.currentAuctionSale && g.richeseAuction.owner === p.id && action.card === g.richeseAuction.cardId ), 'The card reserved for this Black Market auction cannot be played or queued for another effect.', ); try { if ( resolveTruthAction(g, p, action, { completeDefiniteAnswer: finishTruthtranceAnswer, log, shipmentAnswers: shipmentTruthAnswers, bindShipment: bindShipmentTruth, battleAnswers: battleTruthAnswers, bindBattle: bindBattleTruth, cardBlock: transferCardBlock, }) ) return g; } catch (error) { if (error instanceof TruthError || error instanceof PlanClaimError) throw new RuleError(error.message); throw error; } requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing play.'); if (t === 'card' && action.card === RECRUITS_CARD_ID) { requireRule( Object.keys(action).sort().join(',') === 'card,type', 'Play Recruits without an additional mode or selection.', ); const card = p.hand.find((held) => held.id === RECRUITS_CARD_ID); requireRule(card, 'The physical Recruits card is not in your hand.'); const blocked = recruitsPlayBlock(g, p, card); requireRule(!blocked, blocked ?? 'Recruits is unavailable.'); discard(g, p, card.id); for (const player of g.players) player.freeForcesRevived ??= 0; g.recruits = { turn: g.turn, player: p.id, card: RECRUITS_CARD_ID }; log( g, `${p.name} played Recruits. Current free revival rates are doubled and the ordinary force revival limit is seven for this turn.`, { faction: p.faction, name: 'Recruits' }, ); return g; } if (t === 'card' && action.card === 'richese-juice-of-sapho') { playSapho(g, p, action); return g; } if (t === 'tupileIntelligence') { requestTupileIntelligence(g, p, action); return g; } if (t === 'portableSnooper') { requireRule( Object.keys(action).every((key) => ['type', 'card', 'event'].includes(key), ), 'Choose only the held Portable Snooper and current battle event.', ); const { b, card } = validatePortableSnooper( g, p, stringField(action.card), stringField(action.event), ); (b.lateDefense ??= {})[id] = card.id; log( g, `${p.name} played Portable Snooper after revealing their battle plan. It protects against ordinary poison without changing the original plan. Battle and traitor decisions continue.`, { faction: p.faction, name: 'Portable Snooper' }, ); return g; } if (t === 'battlePreparationReady') { const b = g.battle, opportunity = b?.preLeader; requireRule( g.phase === 6 && b && opportunity && !opportunity.closed && !b.revealed && [b.attacker, b.defender].includes(id), 'No shared pre-leader opportunity is waiting for you.', ); requireRule( action.event === opportunity.event && !opportunity.ready.includes(id), 'This preparation declaration is stale or already recorded.', ); requireRule( !g.truthtrance && !g.response && !g.decision && !g.phaseOpening, 'Finish the current interaction before leaving preparation.', ); opportunity.ready.push(id); opportunity.closed = [b.attacker, b.defender].every((player) => opportunity.ready.includes(player), ); log( g, `${p.name} is ready to continue battle preparation${opportunity.closed ? '; both combatants have finished their shared pre-leader opportunity' : ''}.`, ); return g; } if ( t === 'card' && p.hand.some((c) => c.id === action.card && c.effect === 'residualPoison') ) { requireRule( Object.keys(action).every((key) => ['type', 'card', 'target', 'event'].includes(key), ), 'Residual Poison chooses the victim on the server; do not submit a leader or random selection.', ); playResidualPoison( g, p, stringField(action.card), stringField(action.target), stringField(action.event), ); return g; } if ( t === 'card' && p.hand.some((c) => c.id === action.card && c.effect === 'nullentropyBox') ) { requireRule( Object.keys(action).every((key) => key === 'type' || key === 'card'), 'Begin the private Box search before selecting any discard card.', ); beginNullentropy(g, p, stringField(action.card)); return g; } if ( t === 'card' && p.hand.some((c) => c.id === action.card && c.effect === 'distrans') ) { requireRule(!action.mode, 'Use Distrans as its printed card effect.'); const { recipient, result } = validateDistrans( g, p, stringField(action.card), stringField(action.target), stringField(action.give), ); p.hand = result.ownerHand; recipient.hand = result.recipientHand; g.discard.push(result.discarded); log( g, `${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`, { faction: p.faction, name: 'Distrans' }, ); if (g.semutaPreview && !g.pendingTreacheryDiscard) { const continuation: DistransDiscardContinuation = { kind: 'distransDiscard', owner: p.id, recipient: recipient.id, transferred: result.transferred.id, card: result.discarded.id, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: result.discarded, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = distransDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'distrans', entries, continuation); } } return g; } if (t === 'richeseGift') { const { intent } = validateRicheseGift(g, p, stringField(action.card)); g.pendingRicheseGift = { event: crypto.randomUUID(), intent, turn: g.turn, phase: g.phase, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening, }, }; g.response = { kind: 'richeseGift', owner: id, recipient: intent.recipient, passed: [], }; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; log( g, `${p.name} offered their ally one Richese Treachery Card from hand. Its identity stays private; resolve the alliance power before transferring it.`, ); return g; } if (t === 'card' && action.mode === 'special' && p.faction === 'choam') { specialKarama(g, p, action); return g; } if (t === 'card' && choamSaleGholaTiming(g) && p.hand.some((card) => card.id === action.card && card.effect === 'ghola')) { playMarketGhola(g, p, action); return g; } if (t === 'biddingEnd') { decideBiddingEnd(g, p, action); return g; } if (g.phaseOpening) { requireRule( g.status === 'playing', 'Phase opening requires an active game.', ); requireRule( !g.phaseOpening.passed.includes(id), 'You have already passed the phase opening.', ); if (t === 'ready') { g.phaseOpening.passed.push(id); if (g.phaseOpening.passed.length === g.players.length) { const initialize = g.phaseOpening.initialize; g.phaseOpening = null; if (initialize) beginPhase(g); } } else { const card = p.hand.find( (c) => c.id === action.card && c.effect === 'amal', ); requireRule( t === 'card' && card && !action.mode, 'Play Amal or pass the phase opening before taking other actions.', ); const before = g.semutaPreview ? g.players.map(player => ({ player: player.id, spice: player.spice })) : null; const used = discard(g, p, card.id); for (const player of g.players) player.spice = Math.floor(player.spice / 2); g.phaseOpening.passed = []; log( g, `${p.name} played Amal. Each faction returned half its available spice to the bank, rounded up.`, ); if (before) { const continuation: AmalDiscardContinuation = { kind: 'amalDiscard', player: p.id, card: used.id, before, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { g.phaseOpening = null; continuation.stateSignature = amalDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'amal', entries, continuation); } } } return g; } if (t === 'card' && action.mode === 'choam') { playChoamWorthless(g, p, action); return g; } if (t === 'card' && action.mode === 'special') { requireRule( !g.choamMarket, 'Finish the end-of-phase exchange before another special power.', ); requireRule( !g.pendingRevival || (p.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop'), 'Resolve the pending revival first.', ); specialKarama(g, p, action); return g; } if (g.response) { const response = g.response; if (response.kind === 'fremenMovement') pendingFremenMovement(g, response); if (t === 'passResponse') { requireRule( !response.passed.includes(id), 'You have already passed this response window.', ); response.passed.push(id); } else { requireRule( t === 'card' && action.mode === 'cancel', 'Resolve the Karama response window first.', ); requireRule( id !== response.owner, 'Karama cancels another faction’s power.', ); const card = karamaCard(g, p, action.card); requireRule(card, 'Choose a Karama in your hand.'); spendKarama(g, p, card, { kind: 'cancel', response }); } return g; } if ( t === 'offerRicheseNoField' && g.decision?.kind === 'richeseAllyOpportunity' && g.decision.player === id ) g.decision = null; if (t === 'leaderSkillVisibility' || (t === 'leaderSkill' && g.status === 'playing')) { const decision = g.decision; requireRule(decision && (decision.kind === 'leaderSkillVisibility' || decision.kind === 'leaderSkillRevival') && decision.player === id && decision.event === action.event, 'This Leader Skill decision is not waiting for you.'); if (t === 'leaderSkillVisibility') { requireRule(decision.kind === 'leaderSkillVisibility' && g.battle?.event === decision.event && typeof action.hide === 'boolean', 'Choose whether your skilled leader remains face up or moves behind the shield.'); requireRule(Object.keys(action).every((key) => ['type','event','hide'].includes(key)), 'Choose only the current skill visibility decision.'); (g.battle.leaderSkillHidden ??= {})[id] = action.hide; g.decision = null; log(g, `${p.name} ${action.hide ? 'moved their skilled leader and card behind the shield; it may still be a bluff' : 'kept their skilled leader and card face up; that leader cannot be in their Battle Plan'}.`, {faction:p.faction,name:'Leader Skill'}); if (decision.resumePowers !== false) beginBattlePowers(g); } else { requireRule(decision.kind === 'leaderSkillRevival' && g.leaderSkills, 'No revived leader is awaiting a skill choice.'); requireRule(Object.keys(action).every((key) => ['type','event','mode','skill','leader'].includes(key)), 'Choose only the current revival skill action.'); if (action.mode === 'draw') g.leaderSkills = skillRule(() => drawRevivedLeaderSkills(g.leaderSkills!, id, stringField(action.event))); else if (action.mode === 'decline') g.leaderSkills = skillRule(() => declineRevivedLeaderSkill(g.leaderSkills!, id, stringField(action.event))); else { requireRule(action.mode === undefined, 'Choose draw, decline or a dealt skill.'); const skill = stringField(action.skill); const unavailable = leaderSkillAssignmentUnavailable(g, id, skill); requireRule(!unavailable, unavailable ?? 'This Leader Skill is unavailable.'); g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, stringField(action.leader), skillEligibleLeaders(g,id), random)); log(g, `${p.name} assigned ${leaderSkillCard(action.skill as Parameters[0]).name} to a newly revived leader.`, {faction:p.faction,name:'Leader Skill'}); } const marketGhola = g.pendingChoamMarketGhola; if (marketGhola?.player === id && marketGhola.leaderSkill?.offer === decision.event && !g.leaderSkills.offers[id]) { marketGhola.leaderSkill.completed = true; marketGhola.leaderSkill.signature = choamGholaSkillSignature(marketGhola); } g.decision = null; if (g.battle && !g.battle.revealed && !g.battle.plans[id] && [g.battle.attacker,g.battle.defender].includes(id) && g.leaderSkills.assignments.some((a) => a.owner === id) && g.battle.leaderSkillHidden?.[id] === undefined) g.decision = {kind:'leaderSkillVisibility',player:id,event:g.battle.event!,resumePowers:false}; } return g; } if (g.decision) { const decision = g.decision; requireRule( t === 'decision' && decision.player === id, 'Waiting for the player with the pending decision.', ); g.decision = null; requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.'); if (decision.kind === 'moritaniAssassinate') {actMoritaniAssassinate(g,decision,action);return g;} if (decision.kind === 'moritaniExtortion') { const obligation = g.moritaniExtortion; const pay = action.pay; requireRule(g.phase === 8 && obligation?.stage === 'payment' && obligation.queue[obligation.cursor] === id && decision.event === extortionEvent(obligation) && action.event === decision.event && typeof pay === 'boolean' && Object.keys(action).every(key => ['type', 'event', 'pay'].includes(key)), 'Choose the current Extortion opportunity in storm order.'); const result = extortionRule(() => answerExtortion(obligation, id, pay, extortionContext(g))); if (result.transfer?.from === 'player') { p.spice -= result.transfer.amount; getPlayer(g, result.transfer.to).spice += result.transfer.amount; log(g, `${p.name} paid Moritani three spice to prevent Extortion recovery.`); } else log(g, `${p.name} declined the Extortion payment.`); g.moritaniExtortion = result.state; if (result.recover !== null) settleMoritaniExtortion(g, result.state, result.recover); else { const next = result.state.queue[result.state.cursor]; g.decision = { kind: 'moritaniExtortion', player: next, event: extortionEvent(result.state) }; } return g; } if (decision.kind === 'mentatQuestion') { actMentatQuestion(g, decision, action); return g; } if (decision.kind === 'bureaucratPayment') {actBureaucratPayment(g,action);return g;} if (decision.kind === 'ixRicheseTechnology') { const pending = g.pendingIxRicheseTechnology!; requireRule(action.event === pending.event && action.decline === true && Object.keys(action).every(key => ['type', 'event', 'decline'].includes(key)), 'Ixian exchange custody for Richese lots is not implemented. Explicitly continue without this exchange.'); delete g.pendingIxRicheseTechnology; delete g.ixRicheseTechnologyEvent; log(g, `${p.name} continued without exchanging this Richese lot; Technology remains available for later lots.`); beginRicheseLot(g, pending.source, { type: 'decision', card: pending.card.id, method: pending.method, ...(pending.direction !== null ? { direction: pending.direction } : {}), ...(pending.claim !== null ? { claim: pending.claim } : {}) }, true); return g; } if (decision.kind === 'homeworldRevivalDeployment') { decideHomeworldRevivalReturn(g, p, action); return g; } if (decision.kind === 'caladanReinforcement') { decideHomeworldVictoryReturn(g, p, action); return g; } if (decision.kind === 'ecazSpice') { g.decision = decision; decideSharedSpice(g, id, action); return g; } if ( decision.kind === 'richeseBlackMarket' || decision.kind === 'richeseDeclaration' || decision.kind === 'richeseCache' || decision.kind === 'richeseUnbid' ) { decideRichese(g, p, decision, action); return g; } if (decision.kind === 'ecazAmbassador') { decideAmbassador(g, p, action); return g; } if (decision.kind === 'ecazPlacement') { requireRule( g.status === 'playing' && g.phase === 4 && p.faction === 'ecaz' && g.ecazAmbassadors && g.ecazPlacementTurn !== g.turn, 'Place Ambassadors during your end-of-Revival opportunity.', ); if (action.decline === true) { log(g, `${p.name} finished Ambassador placement.`); finishEcazPlacement(g); } else { const token = stringField(action.token), to = stringField(action.territory); const quote = ambassadorPlacementQuote(g, p, token, to); g.pendingEcazPlacement = { token, territory: to, turn: g.turn, cost: quote.cost, }; g.response = { kind: 'ecazPlacement', owner: id, passed: [], intent: `Place the ${faction(g.ecazAmbassadors.tokens.find((t) => t.id === token)!.effect).name} Ambassador in ${territory(to).name} for ${quote.cost} spice.`, }; log( g, `${p.name} declared an Ambassador placement. No spice has been paid yet.`, ); } return g; } if (decision.kind === 'moritaniRetention') { const pending = g.moritaniRetention; requireRule( pending?.stage === 'choose' && pending.player === id && pending.turn === g.turn && g.phase === 6, 'No alliance battle-card choice is pending for you.', ); requireRule( action.keep === null || (typeof action.keep === 'string' && pending.eligible.includes(action.keep) && p.hand.some((c) => c.id === action.keep)), 'Keep one eligible played card, or explicitly keep none.', ); requireRule(decision.source === pending.source, 'The battle-card retention source changed.'); if (pending.source === 'nexus') { const offer = quoteNexusMoritaniRetention(g, id, decision); requireRule(offer?.event === action.event && Object.keys(action).every(key => ['type', 'event', 'keep'].includes(key)) && (action.keep === null || offer?.canKeep === true), 'Choose whether to spend Moritani Nexus after this loss.'); finishMoritaniRetention(g, action.keep as string | null); return g; } if (action.keep === null) finishMoritaniRetention(g, null); else { pending.keep = action.keep as string; pending.stage = 'response'; g.response = { kind: 'moritaniRetention', owner: pending.owner, passed: [], }; } return g; } if (decision.kind === 'grummanCollection') { decideGrummanCollection(g, p, action); return g; } if (decision.kind === 'moritaniTerror') { decideTerror(g, p, action); return g; } if (decision.kind === 'moritaniSetup') { requireRule( g.status === 'setup' && p.faction === 'moritani' && p.reserves === 20 && otherSetupComplete(g), 'Moritani places its starting forces after the other factions finish setup.', ); const to = stringField(action.territory); const sector = integer(action.sector, 0, 18, 'Sector'); requireRule( TERRITORIES.some((t) => t.id === to) && to !== MOBILE_STRONGHOLD && validGameLocation(g, to, sector), 'Choose a printed territory and sector.', ); requireRule( g.players.every((player) => at(player, to) === 0), 'Moritani must start in an unoccupied territory, including no advisors.', ); place(p, to, sector, 6); p.reserves -= 6; log( g, `${p.name} placed six starting forces in ${territory(to).name}, sector ${sector}.`, ); return g; } if (decision.kind === 'richeseAllyOpportunity') { requireRule( action.decline === true, 'Offer a No-Field shipment or let your ally ship normally.', ); log( g, `${p.name} passed the allied No-Field offer opportunity. Their ally may ship normally.`, ); return g; } if (decision.kind === 'richeseAllyShipment') { const offer = g.richeseAllyOffer; requireRule( offer && offer.recipient === id && offer.owner === decision.owner && action.event === offer.event, 'This allied No-Field offer is no longer current.', ); if (action.decline === true) { g.richeseAllyOffer = null; g.richeseAllyDeclined = { turn: g.turn, recipient: id }; log( g, `${p.name} declined the allied No-Field offer. Shipment and token custody remain unchanged.`, ); } else { requireRule(action.accept === true, 'Accept this offer or decline it.'); const quote = alliedNoFieldQuote(g, offer); const elite = integer( action.elite ?? quote.eliteMin, quote.eliteMin, quote.eliteMax, 'Elite forces', ); g.pendingShipment = { turn: g.turn, player: id, territory: offer.territory, sector: offer.sector, amount: quote.amount, elite, cost: quote.cost, allyPayment: 0, advisors: quote.advisors, alliedNoField: offer, }; // Verify the entire arrival continuation before the recipient commits. // Unsupported simultaneous reactions leave this offer declineable. const trial = structuredClone(g); commitShipment(trial, trial.pendingShipment!); g.richeseAllyOffer = null; g.response = { kind: 'richeseNoField', owner: offer.owner, passed: [] }; log( g, `${p.name} accepted an allied No-Field shipment. Resolve the Richese power before payment, token reveal or arrival.`, ); } return g; } if (decision.kind === 'moritaniPlacement') { requireRule( g.status === 'playing' && g.phase === 8 && p.faction === 'moritani' && g.moritaniTerror && g.moritaniTerror.placementTurn !== g.turn, 'Place or relocate one Terror token during your Mentat opportunity.', ); if (action.decline === true) { g.moritaniTerror.placementTurn = g.turn; log(g, `${p.name} passed the Terror placement opportunity.`); finishMoritaniPlacement(g); } else { const token = stringField(action.token); const to = stringField(action.territory); const nexus = action.nexus !== undefined; if (nexus) { const offer = nexusMoritaniOffer(g,p.id,decision); requireRule(offer && !offer.blocked && offer.event === action.nexus && Object.keys(action).every(key => ['type','token','territory','nexus'].includes(key)) && offer.tokens.some(t => t.id === token) && offer.destinations.includes(to), offer?.blocked ?? 'Choose your current Moritani Cunning supply placement.'); } try { if (nexus) quoteNexusMoritaniPlacement(g.moritaniTerror,token,to,g.turn); else placeTerror(g.moritaniTerror, token, to, g.turn); } catch (error) { throw new RuleError( error instanceof Error ? error.message : 'Invalid Terror placement.', ); } if (nexus) recordNexusMoritani(g,p.id,token,to); else g.pendingMoritaniPlacement = { token, territory: to, turn: g.turn }; if (nexus) log(g, `${p.name} played Moritani Nexus Cunning for this hidden Terror placement. Karama may prevent the placement; the Nexus remains spent.`); g.response = { kind: 'moritaniPlacement', owner: id, passed: [] }; log(g, `${p.name} declared a hidden Terror placement or relocation.`); } return g; } if (decision.kind === 'choamStorm') { requireRule( action.decline === true && g.stormResolution, 'Continue the storm or declare Jubba Cloak.', ); beginStormProtection(g); return g; } if (decision.kind === 'choamMovement') { requireRule( action.decline === true, 'Allow the movement or declare Baliset.', ); resumeChoamMovement(g, true); return g; } if (decision.kind === 'nexusChoamInspection') { const offer = quoteNexusChoamInspection(g, id, decision); requireRule( offer?.event === decision.event && action.event === decision.event && typeof action.inspect === 'boolean' && Object.keys(action).every(key => ['type', 'event', 'inspect'].includes(key)), 'Choose whether to use CHOAM Secret Ally for this resolved victory.', ); const receipt = g.lastBattleContext!.nexusChoamInspection!; if (action.inspect) { requireRule(offer.canInspect, 'The CHOAM Secret Ally needs its unallied holder and an unused opposing card.'); const opponent = getPlayer(g, receipt.opponent); const card = nexusRule(() => sampleNexusChoamInspection(opponent.hand, receipt.usedCards, random)); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, id, g.players)); receipt.inspected = card.id; g.nexusChoamInsight = { event: receipt.event, viewer: id, target: opponent.id, turn: g.turn, card, }; log(g, `${p.name} spent CHOAM Nexus Secret Ally to privately inspect one unused card held by ${opponent.name} after winning the battle. The inspected face remains private.`, { faction: p.faction, name: 'CHOAM Nexus inspection' }); } receipt.stage = 'complete'; receipt.signature = nexusChoamInspectionSignature(receipt); g.decision = null; finishBattle(g); return g; } if ( decision.kind === 'choamAudit' || decision.kind === 'choamAuditPayment' ) { const pending = g.pendingAuditor; requireRule( pending && action.event === pending.event && action.event === decision.event, 'This Auditor decision is stale or already completed.', ); requireRule( Object.keys(action).every((key) => ['type', 'event', 'audit', 'pay', 'count'].includes(key), ), 'Choose only whether to audit or pay the current cancellation cost.', ); if (decision.kind === 'choamAudit') { requireRule( typeof action.audit === 'boolean' && action.pay === undefined && action.count === undefined, 'Choose whether to use the Auditor.', ); if (!action.audit) finishAuditor(g, 'decline'); else { pending.stage = 'response'; g.response = { kind: 'choamAudit', owner: id, intent: pending.event, passed: [], }; log( g, `${p.name} declared an Auditor inspection. Opposing Karama cancellation resolves before any inspection payment.`, ); } } else { requireRule( typeof action.pay === 'boolean' && action.audit === undefined, 'Pay the full cancellation cost or allow the audit.', ); if (action.pay) requireRule( action.count === currentAuditCount(g), 'The eligible-card count changed. Review the current full cancellation cost.', ); else requireRule( action.count === undefined, 'Allowing an audit does not submit a payment.', ); finishAuditor(g, action.pay ? 'pay' : 'inspect'); } return g; } if (decision.kind === 'strongholdCopy') { const b = g.battle; requireRule( b?.event === decision.event && action.event === decision.event && b.territory === MOBILE_STRONGHOLD && !Object.keys(b.plans).length && !b.strongholdCopy && g.strongholdCards?.owners.hidden_mobile_stronghold === id, 'Choose a copied Stronghold advantage for this exact battle before plans.', ); const choices = strongholdCopyChoices(g, id); requireRule( typeof action.stronghold === 'string' && decision.choices.includes(action.stronghold as StrongholdId) && choices.includes(action.stronghold as StrongholdId), 'Choose another stronghold you currently control.', ); b.strongholdCopy = action.stronghold as StrongholdId; log( g, `${p.name} declared ${STRONGHOLD_CARDS.find((card) => card.id === b.strongholdCopy)!.name} as their copied mobile Stronghold advantage.`, { faction: p.faction, name: 'Stronghold advantage' }, ); beginBattlePowers(g); return g; } if (decision.kind === 'choamMentat') { requireRule( action.done === true && g.choamMentatPending && g.phase === 8, 'Finish CHOAM’s Mentat opportunity before checking victory.', ); g.choamMentatPending = false; victory(g); if (g.status === 'playing') completePhase(g); return g; } if (decision.kind === 'choamFreeRevival') { requireRule( action.decline === true, 'Allow the free revival or play La La La.', ); offerRevivalStop(g); return g; } if (decision.kind === 'choamBattleFunding') { requireRule( p.faction === 'choam' && g.advanced && g.phase === 6 && g.battle, 'CHOAM battle funding is unavailable.', ); pledgeAid(g, p, action.amount); battlePreparation(g, 'voice'); combatResponses(g); return g; } if ( decision.kind === 'choamMarket' || decision.kind === 'choamTradeReply' || decision.kind === 'choamTradeConfirm' ) { decideChoamMarket(g, decision, action); } else if (decision.kind === 'ixSetup') { requireRule( g.status === 'setup' && p.faction === 'ixians' && g.ixSetupCards, 'Ixian starting-card selection is not available.', ); const card = g.ixSetupCards.find((c) => c.id === action.card); requireRule(card, 'Choose one of the starting cards.'); p.hand.push(card); const remainder = shuffle(g.ixSetupCards.filter((c) => c.id !== card.id)); for (const other of g.players.filter((other) => other.id !== p.id)) { const dealt = remainder.shift(); if (dealt) other.hand.push(dealt); } const harkonnen = byFaction(g, 'harkonnen'); if (harkonnen) { const bonus = draw(g); if (bonus) harkonnen.hand.push(bonus); } g.ixSetupCards = null; log( g, `${p.name} selected a starting card. The other starting cards were shuffled and dealt privately.`, ); } else if (decision.kind === 'ixAuction') { requireRule( g.ixAuction && p.faction === 'ixians', 'The auction pool is not available.', ); const card = g.ixAuction.cards.find((c) => c.id === action.card); requireRule(card, 'Choose a card from the auction pool.'); requireRule( action.position === 'top' || action.position === 'bottom', 'Return the card to the top or bottom of the deck.', ); if (action.position === 'top') g.deck.unshift(card); else g.deck.push(card); const rest = g.ixAuction.cards.filter((c) => c.id !== card.id); g.ixAuctionKnown = { turn: g.turn, cards: [...rest] }; g.ixAuction = null; setAuction(g, shuffle(rest)); log( g, `${p.name} returned one card face down to the ${action.position} of the deck and shuffled the auction pool.`, ); } else if (decision.kind === 'ixTechnology') { requireRule( g.advanced && p.faction === 'ixians' && g.auction && g.ixTechnologyTurn !== g.turn, 'Ixian auction substitution is not available.', ); if (action.decline === true) offerAuctionPeek(g); else { const card = p.hand.find((c) => c.id === action.card); requireRule( card, 'Choose one of your hand cards to offer for exchange.', ); g.ixTechnologyTurn = g.turn; g.pendingIxTechnology = { card: card.id }; g.response = { kind: 'ixTechnology', owner: p.id, passed: [] }; } } else if (decision.kind === 'ixAllyCard') { const pending = g.pendingIxAlly; requireRule( pending && pending.player === p.id, 'This purchased-card choice is not available.', ); requireRule( typeof action.accept === 'boolean', 'Keep or replace the purchased card.', ); if (action.accept) { const ixians = byFaction(g, 'ixians'); requireRule( ixians && p.ally === ixians.id && p.hand.some((c) => c.id === pending.card), 'The purchased card and Ixian alliance must remain available.', ); g.response = { kind: 'ixAllyCard', owner: ixians.id, recipient: p.id, passed: [], }; } else { g.pendingIxAlly = null; continueAuctionSale(g, pending.free); } } else if (decision.kind === 'mobileStronghold') { requireRule( p.faction === 'ixians' && g.mobileStronghold, 'Only Ixians place or move their stronghold.', ); if (decision.placement) { const dest = stringField(action.location); const loc = splitLocation(dest); requireRule( TERRITORIES.some( (t) => t.id === loc.territory && t.sectors.includes(loc.sector) && t.type !== 'stronghold', ) && location(loc.territory, loc.sector) === dest, 'Point the stronghold at a sector in a non-stronghold territory.', ); g.mobileStronghold.location = dest; log( g, `${p.name} placed the Hidden Mobile Stronghold at ${territory(loc.territory).name}, sector ${loc.sector}.`, ); finishStorm(g); } else if (action.decline === true) beginStormTurn(g); else { const route = validateMobileMove(g, p, action.route, 3); g.pendingMobileMove = { player: p.id, route, collect: action.collect !== false, }; g.response = { kind: 'mobileStronghold', owner: p.id, location: route.at(-1), passed: [], }; } } else if (decision.kind === 'ixSubstitution') { const pending = g.pendingIxSubstitution!; if (action.decline === true) { completeIxSubstitution(g); finishWinner(g, p, pending.territory, pending.cards); } else { const parse = (input: unknown, available: Record) => { requireRule( input && typeof input === 'object' && !Array.isArray(input), 'Choose sector counts for the substitution.', ); return Object.fromEntries( Object.entries(input as Record) .map(([key, value]) => { requireRule( Object.hasOwn(available, key), 'Choose a sector in this battle.', ); return [key, integer(value, 0, available[key], 'Forces')]; }) .filter(([, count]) => Number(count) > 0), ) as Record; }; const sources = pending.territory.startsWith('homeworld:') ? { [pending.territory]: combatArmy(g, p.id, pending.territory).normal } : Object.fromEntries( Object.entries(p.forces) .filter( ([key]) => splitLocation(key).territory === pending.territory, ) .map(([key, count]) => [key, count - (p.elites?.forces[key] ?? 0)]), ); pending.sources = parse(action.sources, sources); pending.recover = parse(action.recover, pending.losses); const sum = (counts: Record) => Object.values(counts).reduce((a, b) => a + b, 0); requireRule( sum(pending.sources) > 0 && sum(pending.sources) === sum(pending.recover), 'Exchange one surviving suboid for each cyborg lost in this battle.', ); if (pending.receipt) { pending.receipt.signature = ixSubstitutionSignature(pending); g.lastBattleContext!.ixSubstitution!.signature = pending.receipt.signature; } g.response = { kind: 'ixSubstitution', owner: id, passed: [] }; } } else if (decision.kind === 'revivalStop') { requireRule( action.decline === true, 'Allow the revival, or spend special Karama to prevent it.', ); finishRevival(g); } else if (decision.kind === 'faceDance') { requireRule( typeof action.reveal === 'boolean', 'Reveal a Face Dancer or decline.', ); if (action.reveal) { const dancer = p.faceDancers?.find( (c) => !c.revealed && c.leader === decision.identity, ); requireRule( dancer, 'You do not have an unrevealed Face Dancer for that leader.', ); const winner = getPlayer(g, decision.winner); const blocked = faceDanceReturnBlock(g, winner.id); requireRule(!blocked, blocked ?? 'This Face Dance return is unavailable.'); const home = homeworldBattleLocation(g, decision.territory); requireRule(!home || home.native === p.id, 'Only the native faction may call a Face Dancer on a Homeworld.'); const army = combatArmy(g, winner.id, decision.territory); const maximum = home ? army.normal + army.elite : at(winner, decision.territory); const sources = action.sources; requireRule( sources && typeof sources === 'object' && !Array.isArray(sources), 'Choose reserves or board forces to replace the winner.', ); const selected = Object.entries(sources).map(([key, value]) => { requireRule( key === 'reserves' || key in p.forces, 'Choose your own reserves or occupied sectors.', ); return { key, count: integer( value, 0, key === 'reserves' ? p.reserves : p.forces[key], 'Forces', ), }; }); const total = selected.reduce((sum, source) => sum + source.count, 0); requireRule( total <= maximum, 'You cannot replace more forces than the winner has remaining.', ); const sector = home ? 0 : integer(action.sector, 0, 18, 'Sector'); requireRule(home || validGameLocation(g, decision.territory, sector), 'Choose a sector in the battle territory.'); // This is replacement, not shipment or movement: no transport cost or worm/storm transit. for (const source of selected) { if (source.key === 'reserves') { if (!home) p.reserves -= source.count; } else { p.forces[source.key] -= source.count; if (!p.forces[source.key]) delete p.forces[source.key]; } } if (home) { const destination = `homeworld:${winner.faction}`; const external = selected.filter((source) => source.key !== 'reserves').reduce((n, source) => n + source.count, 0); const quote = quoteHomeworldCustody(homeworldContext(g), g.homeworlds!.custody!, [ { homeworld: home.id, player: winner.id, withdraw: army, deposit: {normal: 0, elite: 0} }, { homeworld: destination, player: winner.id, withdraw: {normal: 0, elite: 0}, deposit: army }, { homeworld: home.id, player: p.id, withdraw: {normal: 0, elite: 0}, deposit: {normal: external, elite: 0} }, ]); g.homeworlds!.custody = quote.state; for (const update of quote.players) { const owner = getPlayer(g, update.id); owner.reserves = update.reserves; if (owner.elites) owner.elites.reserves = update.eliteReserves; } } else { for (const key of Object.keys(winner.forces).filter( (key) => splitLocation(key).territory === decision.territory, )) { winner.reserves += winner.forces[key]; if (winner.elites) { winner.elites.reserves += winner.elites.forces[key] ?? 0; delete winner.elites.forces[key]; } delete winner.forces[key]; } } const leader = g.players .flatMap((player) => player.leaders) .find((l) => l.id === decision.leader); if (leader && !leader.dead) { leader.dead = true; leader.deaths++; delete leader.usedAt; delete leader.capturedBy; delete leader.concealed; } if (total && !home) place(p, decision.territory, sector, total); observeOccupation(g); dancer.revealed = true; log( g, `${p.name} revealed a Face Dancer and replaced ${total} forces in ${combatLocationName(g, decision.territory)}.`, ); if (p.faceDancers!.every((c) => c.revealed)) { g.traitorReserve = shuffle([ ...(g.traitorReserve ?? []), ...p.faceDancers!.map((c) => c.leader), ]); p.faceDancers = g.traitorReserve .splice(0, 3) .map((leader) => ({ leader, revealed: false })); log(g, `${p.name} drew three new Face Dancers.`); } } g.pendingFaceDance = null; const harkonnen = byFaction(g, 'harkonnen'); if (harkonnen) returnCaptives(g, harkonnen); settleAdvisors(g); finishBattle(g); } else if (decision.kind === 'diplomatRetreat') { const receipt = g.battle?.diplomatRetreat; const destination = action.destination; const normal = action.normal, elite = action.elite; requireRule(Object.keys(action).every(key => ['type', 'event', 'destination', 'normal', 'elite'].includes(key)) && receipt?.event === decision.event && action.event === decision.event && receipt.player === id && (destination === null && normal === 0 && elite === 0 || typeof destination === 'string' && receipt.destinations.some(option => option.location === destination && option.choices.some(choice => choice.normal === normal && choice.elite === elite))), 'Choose a legal empty adjacent territory and undialed force count, or decline this Diplomat retreat.'); g.decision = null; if (destination === null) log(g, `${p.name} declined the Diplomat retreat.`, { faction: p.faction, name: 'Diplomat retreat' }); resolveBattle(g, { destination: destination as string | null, normal: normal as number, elite: elite as number }); } else if (decision.kind === 'harassWithdraw') { const receipt = g.battle?.harassAllocation, offer = harassAllocationOffer(g); requireRule(Object.keys(action).every(key => ['type', 'event', 'returns'].includes(key)) && action.event === decision.event && receipt?.event === decision.event && receipt.player === id && receipt.stage === 'offered' && offer && action.returns !== undefined, 'Choose all undialed forces for this exact revealed Harass & Withdraw plan.'); let quote; try { quote = quoteHarassWithdraw(offer.context, offer.dial, offer.support, action.returns as HarassWithdrawSelection); } catch (error) { if (error instanceof HarassWithdrawError) throw new RuleError(error.message); throw error; } validateReserveReturnCounters(p, quote.returned); receipt.selection = structuredClone(quote.locations); receipt.stage = 'selected'; receipt.signature = harassAllocationSignature(receipt); log(g, `${p.name} selected ${quote.returned.normal} ordinary and ${quote.returned.elite} elite undialed forces for Harass & Withdraw: ${Object.entries(quote.locations).map(([key, group]) => `${key} (${group.normal} ordinary, ${group.elite} elite)`).join('; ')}. The return waits for traitor declarations; an opposing successful call cancels it.`); nextRevealedDecision(g); } else if (decision.kind === 'diplomatDefense') { const receipt = g.battle?.diplomatDefense; requireRule(Object.keys(action).every(key => ['type', 'event', 'card'].includes(key)) && action.event === decision.event && receipt?.event === decision.event && receipt.player === id && receipt.stage === 'offered' && (action.card === null || typeof action.card === 'string' && receipt.cards.includes(action.card)), 'Choose one offered Worthless card or decline this exact Diplomat defense.'); receipt.card = action.card as string | null; receipt.stage = receipt.card ? 'copied' : 'declined'; receipt.signature = diplomatDefenseSignature(receipt); log(g, receipt.card ? `${p.name} used Diplomat: ${cardOf(p, receipt.card)!.name} copies the opposing ${receipt.kind === 'shield' ? 'Shield' : 'Snooper'} for this battle and must be discarded afterward. The original plans and physical card identities remain unchanged.` : `${p.name} declined to copy the opposing defense with Diplomat.`, { faction: p.faction, name: 'Diplomat defense' }); nextRevealedDecision(g); } else if (decision.kind === 'stoneBurner') { requireRule( Object.keys(action).every((key) => ['type', 'event', 'mode'].includes(key)) && (action.mode === 'kill' || action.mode === 'ignore') && g.battle?.revealed && action.event === decision.event && action.event === g.battle.event, 'Choose Stone Burner’s mode for this exact revealed battle.', ); const choice = revealedWeaponChoices(g).find((candidate) => candidate.kind === 'stoneBurner' && candidate.player === id); requireRule(!!choice && choice.physicalId === (decision.physicalId ?? choice.physicalId) && choice.copiedFrom === (decision.copiedFrom ?? null) && g.battle.stoneBurner?.[id] === undefined, 'Stone Burner’s mode is already chosen or its physical weapon changed.'); (g.battle.stoneBurner ??= {})[id] = action.mode; log(g, `${p.name} chose ${choice.copiedFrom ? 'Mirror Weapon copying ' : ''}Stone Burner: ${ action.mode === 'kill' ? 'both participating leaders will be killed' : 'surviving leaders add no strength; other weapon attacks still apply' }. Undialed physical force tokens decide the ordinary battle; traitors and explosions retain precedence.`); nextRevealedDecision(g); } else if (decision.kind === 'poisonTooth') { requireRule(Object.keys(action).every((key) => ['type', 'event', 'activate'].includes(key)) && g.battle?.revealed && typeof action.activate === 'boolean' && (decision.copiedFrom === null || decision.copiedFrom === undefined || action.event === decision.event) && (action.event === undefined || action.event === decision.event) && (decision.event === undefined || decision.event === g.battle.event), 'Choose whether to activate Poison Tooth for this exact revealed battle.'); const choice = revealedWeaponChoices(g).find((candidate) => candidate.kind === 'poisonTooth' && candidate.player === id); requireRule(!!choice && choice.physicalId === (decision.physicalId ?? choice.physicalId) && choice.copiedFrom === (decision.copiedFrom ?? null) && g.battle.poisonTooth?.[id] === undefined, 'Poison Tooth is already chosen or its physical weapon changed.'); (g.battle.poisonTooth ??= {})[id] = action.activate; log(g, `${p.name} ${action.activate ? 'activated' : 'declined to activate'} ${ choice.copiedFrom ? 'Mirror Weapon copying ' : ''}Poison Tooth.`); nextRevealedDecision(g); } else if (decision.kind === 'techToken') { requireRule( decision.choices.includes(action.token as TechId) && g.techTokens?.[action.token as TechId]?.owner === decision.loser, 'Choose one tech token controlled by the defeated faction.', ); transferTech(g, action.token as TechId, id); g.pendingTech = null; finishBattle(g); } else if (decision.kind === 'fullPlanOffer') { requireRule( action.decline === true, 'Decline the inspection, or spend the Atreides special Karama.', ); } else if (decision.kind === 'fullPlanRead') { requireRule( action.continue === true, 'Confirm that you have inspected the battle plan.', ); } else if (decision.kind === 'homeworldShipmentGuild') { requireRule(action.allow === true && action.event === decision.event, 'Allow this exact Homeworld declaration, or use the Guild special Karama.'); const shipment = g.pendingHomeworldShipment; requireRule(shipment && decision.player === byFaction(g, 'guild')?.id && decision.event === shipment.event && decision.shipper === shipment.player && decision.destination === shipment.destination && decision.amount === shipment.amount, 'The Guild decision no longer matches this Homeworld shipment.'); commitHomeworldShipment(g, shipment); } else if (decision.kind === 'guildShipment') { requireRule( action.allow === true, 'Allow the shipment, or play the Guild special Karama.', ); const shipment = validateGuildShipmentDecision(g, decision); try { quoteShipmentArrival(g, shipment, true); } catch (error) { if (!(error instanceof MovementArrivalError)) throw error; requireRule( recoverableUnsupportedShipment(shipment), error.message, ); g.pendingShipment = null; g.decision = null; log( g, `${getPlayer(g, shipment.player).name}'s uncommitted shipment returned for another choice. ${error.message} No forces, spice, cards or shipment allowance were spent.`, ); return g; } if (!openGuildRate(g, shipment)) { g.pendingShipment = null; commitShipment(g, shipment); } } else if (decision.kind === 'nullentropy') { throw new RuleError( 'No matching paid search was saved; restore the saved search before continuing.', ); } else if (decision.kind === 'handExchange') { finishHarkonnenExchange(g, decision, action.returnCards); } else if (decision.kind === 'captureOffer') { requireRule( typeof action.accept === 'boolean', 'Choose whether to capture a leader.', ); if (action.accept) g.response = { kind: 'capture', owner: id, passed: [] }; else { g.pendingCapture = null; finishBattle(g); } } else if (decision.kind === 'capturedLeader') { requireRule( action.mode === 'keep' || action.mode === 'execute', 'Choose keep or execute for the captured leader.', ); const owner = getPlayer(g, decision.owner), l = owner.leaders.find((l) => l.id === decision.leader)!; if (action.mode === 'execute') { l.dead = true; l.deaths = Math.max(2, l.deaths + 1); delete l.usedAt; delete l.capturedBy; p.spice += 2; log(g, `${p.name} executed a captive for 2 spice.`); } else { returnCaptives(g, p); log( g, `${p.name} ${l.capturedBy === id ? 'retained' : 'released'} the captive.`, ); } finishBattle(g); } else if (decision.kind === 'guildTiming') { requireRule( typeof action.take === 'boolean', 'Choose whether to ship and move now or wait.', ); const inOrder = decision.next === id; if (action.take === inOrder) g.active = action.take ? id : decision.next; else if (g.guildTimingGranted) chooseGuildTiming(g, action.take); else g.response = { kind: 'guildTiming', owner: id, passed: [], take: action.take, }; } else if (decision.kind === 'stormLosses') { const elite = integer( action.elite ?? decision.minElite, decision.minElite, decision.maxElite, 'Elite casualties', ); kill(g, p, decision.key, decision.amount, false, elite); if (decision.resume === 'storm') continueStorm(g); } else if (decision.kind === 'homeworldDefense') { const b = g.battle; requireRule(b?.revealed && b.territory.startsWith('homeworld:') && b.event === decision.event && action.event === decision.event && [b.attacker, b.defender].includes(id) && !traitorVoters(g, b).includes(id) && !b.homeworldDefensePassed?.includes(id), 'This Homeworld late-defense choice is no longer current.'); requireRule(typeof action.use === 'boolean', 'Use Portable Snooper or decline its late-defense opportunity.'); if (action.use) { const card = p.hand.find(isPortableSnooper); requireRule(card, 'The late defense needs your held Portable Snooper.'); validatePortableSnooper(g, p, card.id, decision.event); (b.lateDefense ??= {})[id] = card.id; log(g, `${p.name} added Portable Snooper after the Homeworld battle plans were revealed.`, {faction: p.faction, name: 'Portable Snooper'}); } (b.homeworldDefensePassed ??= []).push(id); advanceHomeworldReveal(g); } else if (decision.kind === 'homeworldExplosion') { homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); requireRule(action.event === decision.event, 'This Homeworld casualty choice belongs to a different battle.'); const choice = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Casualty choice')]; settleHomeworldExplosion(g, choice, false); } else if (decision.kind === 'sukRescue') { requireRule(action.event === decision.event && Object.keys(action).every((key) => ['type', 'event', 'choice'].includes(key)), 'Choose only the current Suk Graduate rescue.'); const option = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Rescue choice')]; settleSukRescue(g, option, false); } else if (decision.kind === 'rihani') { actRihani(g, p, decision, action); } else if (decision.kind === 'battleLosses') { const choice = decision.options[ integer( action.choice, 0, decision.options.length - 1, 'Casualty choice', ) ]; settleWinnerCasualties(g, p, decision.territory, decision.cards, choice); } else if (decision.kind === 'auctionPayment') { normalKaramaAuction(g, p, true, action.karama === true); requireRule( typeof action.karama === 'boolean', 'Choose spice payment or Karama.', ); if (action.karama) { const card = karamaCard(g, p, action.card); requireRule(card, 'You do not hold a Karama card.'); spendKarama(g, p, card, { kind: 'auctionPayment' }); } else settleAuction(g, false); } else if (decision.kind === 'intrusion') { requireRule( typeof action.accept === 'boolean', 'Choose whether to become advisors.', ); const details = { advisorFollowup: decision.followup, advisorResume: decision.ambassadorEvent ? ('ambassador' as const) : decision.discoveryEntry ? ('discoveryEntry' as const) : decision.wormRide ? ('wormRide' as const) : undefined, advisorAmbassadorEvent: decision.ambassadorEvent, discoveryEntry: decision.discoveryEntry, }; if (action.accept) g.response = { kind: 'advisorFlip', owner: id, location: decision.territory, advisors: true, passed: [], ...details, }; else finishAdvisorReaction(g, details); } else if (decision.kind === 'advisorBattle') { requireRule( typeof action.accept === 'boolean', 'Choose whether to prepare advisors for battle.', ); if (action.accept) { const to = stringField(action.territory); requireRule( decision.territories.includes(to) && advisorBattleOptions(g).includes(to), 'These advisors cannot prepare for battle.', ); g.response = { kind: 'advisorFlip', owner: id, location: to, advisors: false, passed: [], advisorResume: 'declaration', advisorRemaining: decision.territories.filter((t) => t !== to), }; } else movementTurn(g); } else if (decision.kind === 'advisor') { requireRule( typeof action.accept === 'boolean', 'Choose whether to ship an advisor.', ); if (decision.ambassadorEvent !== undefined) { const { order } = guildAmbassadorArrival( g, decision.ambassadorEvent, 'advisor', ); requireRule( decision.shipment === order.player && p.faction === 'beneGesserit' && p.id !== order.player, 'This accompaniment does not belong to its Guild Ambassador shipment.', ); if (action.accept) { const quote = guildAmbassadorRule(() => quoteGuildAmbassadorAdvisor(g, p.id, order, action), ); validateGuildAdvisorEntry(g, quote); g.response = { kind: 'advisor', owner: id, passed: [], location: location(quote.territory, quote.sector), amount: quote.amount, advisorResume: 'ambassador', advisorAmbassadorEvent: decision.ambassadorEvent, }; } else { log( g, `${p.name} declined the free accompaniment to the Guild Ambassador shipment.`, ); finishAmbassador(g); } } else if (action.accept) { const target = action.accompany === true && g.advanced ? decision.destination : 'polar_sink:0'; requireRule(target, 'This shipment has no accompanying destination.'); const dest = splitLocation(target); const amount = integer(action.amount === undefined ? 1 : action.amount, 1, spiritualAdvisorMaximum(g, p.id, dest.territory), 'Spiritual Advisor forces'); if ( g.advanced && action.accompany === true && action.sector !== undefined ) dest.sector = integer(action.sector, 0, 18, 'Sector'); const advisors = arrivalAsAdvisor( g, p, dest.territory, undefined, action.accompany === true, ); allowedEntry(g, p, dest.territory, dest.sector, false, advisors); g.response = { kind: 'advisor', owner: id, passed: [], location: location(dest.territory, dest.sector), amount, }; } else log(g, `${p.name} declined the free shipment.`); } else if (decision.kind === 'wormPlacement') { requireRule( typeof action.accept === 'boolean', 'Choose whether to place the additional worm.', ); if (action.accept) { const t = stringField(action.territory); requireRule( TERRITORIES.some((x) => x.id === t && x.type === 'sand'), 'Additional worms must be placed in sand territory.', ); g.response = { kind: 'wormPlacement', owner: id, location: t, passed: [], }; } else continueSpice(g); } else if (decision.kind === 'nexusFremenCunningOffer') { const pending = g.nexusFremenCunningOffer; requireRule(pending && pending.owner === id && pending.occurrence.event === decision.event && action.event === decision.event && typeof action.accept === 'boolean' && Object.keys(action).every(key => ['type', 'event', 'accept'].includes(key)), 'Choose whether to use the current empty-worm Nexus opportunity.'); g.nexusFremenCunningOffer = null; if (action.accept) { requireRule(nexusRule(() => quoteFremenCunning(g, id, pending.occurrence)), 'Fremen Cunning needs your own unallied Nexus card and a desert force source.'); g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, id, g.players)); g.nexusFremenCunningRides ??= []; g.nexusFremenCunningRides.push({ occurrence: pending.occurrence, authorization: { event: decision.event, turn: g.turn, owner: id }, stage: 'pending', }); g.response = { kind: 'nexusFremenCunning', owner: id, intent: decision.event, location: pending.occurrence.territory, passed: [] }; log(g, `${p.name} spent Fremen Nexus Cunning on an empty worm appearance; a Karama response precedes the remote ride.`, { faction: p.faction, name: 'Fremen Nexus Cunning' }); } else wormSurvival(g, pending.occurrence.territory); } else if (decision.kind === 'wormProtection') { requireRule( typeof action.accept === 'boolean', 'Choose whether to protect your ally.', ); if (action.accept) { g.response = { kind: 'wormAllyProtection', owner: id, location: decision.territory, recipient: decision.ally, passed: [], }; } else wormSurvival(g, decision.territory); } else if (decision.kind === 'discoveryDiscard') { decideDiscoveryDiscard(g,p,action); } else if (decision.kind === 'discoveryEntry') { decideDiscoveryEntry(g,p,action); } else if (decision.kind === 'ecologicalStorm') { requireRule(g.ecologicalStorm && Object.keys(action).every(key => ['type','event','delta'].includes(key)), 'Choose only the current station storm adjustment.'); g.ecologicalStorm = nexusRule(() => chooseDiscoveryStorm(g,g.ecologicalStorm!,p.id,action.event,action.delta)); g.stormPending = g.ecologicalStorm.base+g.ecologicalStorm.delta!; log(g, `${p.name} ${g.ecologicalStorm.delta === 0 ? 'kept' : g.ecologicalStorm.delta === 1 ? 'increased' : 'decreased'} storm movement ${g.ecologicalStorm.delta === 0 ? 'at' : 'by one, to'} ${g.stormPending} sectors using Ecological Testing Station.`, {faction:p.faction,name:'Ecological Testing Station'}); moveStorm(g,g.stormPending); } else if (decision.kind === 'greatMakerVote') { decideGreatMakerVote(g,p,action); } else if (decision.kind === 'greatMakerRide') { decideGreatMakerRide(g,p,action); } else if (decision.kind === 'nexusFremenCunningRide') { const ride = g.nexusFremenCunningRides?.find(row => row.stage === 'select' && row.occurrence.event === decision.event); requireRule(ride && ride.authorization.owner === id && action.event === decision.event && typeof action.accept === 'boolean', 'Choose the pending Fremen Cunning remote worm ride.'); if (action.accept) { requireRule(Object.keys(action).every(key => ['type', 'event', 'accept', 'source', 'forces', 'territory', 'sector'].includes(key)), 'Choose only this Cunning source, forces and destination.'); const selection = { source: stringField(action.source), forces: action.forces as FremenCunningSelection['forces'], destination: { territory: stringField(action.territory), sector: integer(action.sector, 0, 18, 'Sector'), }, }; const chosen = nexusRule(() => validateFremenCunningSelection( g, id, ride.occurrence, selection, ride.authorization)); allowedEntry(g, p, selection.destination.territory, selection.destination.sector); const entries = Object.entries(selection.forces); removeGroup(p, entries.map(([key, count]) => [key, count.normal + count.elite] as [string, number]), Object.fromEntries(entries.map(([key, count]) => [key, count.elite]))); place(p, selection.destination.territory, selection.destination.sector, chosen.total, chosen.elite); ride.stage = 'complete'; log(g, `${p.name} used Fremen Nexus Cunning to ride ${chosen.total} forces from ${territory(selection.source).name} to ${territory(selection.destination.territory).name}.`, { faction: p.faction, name: 'Fremen Nexus Cunning' }); const intruded = intrusion(g, p, selection.destination.territory, { wormRide: true }); if (openTerritoryEntry(g, p, selection.destination.territory, selection.destination.sector, chosen.total, chosen.elite, 'wormRide', 'wormRide') || intruded) return g; } else { requireRule(Object.keys(action).every(key => ['type', 'event', 'accept'].includes(key)), 'Declining Cunning does not move forces.'); ride.stage = 'complete'; log(g, `${p.name} declined the Fremen Nexus remote ride.`); } nextWormRide(g); } else if (decision.kind === 'wormRide') { requireRule( typeof action.accept === 'boolean', 'Choose whether to ride Shai-Hulud.', ); requireRule(action.sandmasterCollect === undefined || typeof action.sandmasterCollect === 'boolean', 'Choose whether to collect spice with Sandmaster.'); requireRule(action.accept || !action.sandmasterCollect, 'Sandmaster collection requires an accepted worm ride.'); if (action.accept) { const to = stringField(action.territory); const sector = integer(action.sector, 0, 18, 'Sector'); requireRule( to !== decision.territory, 'Choose another destination territory.', ); allowedEntry(g, p, to, sector); const sandmaster = action.sandmasterCollect ? sandmasterWormCollection(g, p.id, to, sector, decision) : null; if (action.sandmasterCollect) requireRule(sandmaster && !sandmaster.blocked, sandmaster?.blocked ?? 'Sandmaster collection needs the living native trainer.'); requireRule( action.forces && typeof action.forces === 'object' && !Array.isArray(action.forces), 'Choose forces from the worm territory.', ); let total = 0, totalElite = 0; for (const [key, value] of Object.entries(action.forces)) { const source = splitLocation(key); requireRule( source.territory === decision.territory && validGameLocation(g, source.territory, source.sector), 'Ride only from the territory where the worm appeared.', ); const n = integer(value, 0, p.forces[key] ?? 0, 'Forces'); if (!n) continue; requireRule( source.sector !== g.storm, 'Forces cannot leave a sector in storm.', ); const elite = eliteChoice( n, p.forces[key], p.elites?.forces[key] ?? 0, (action.eliteForces as Record | undefined)?.[key], ); removeGroup(p, [[key, n]], { [key]: elite }); totalElite += elite; total += n; } requireRule(total > 0, 'Choose at least one force to ride.'); place(p, to, sector, total, totalElite); log( g, `${p.name} rode Shai-Hulud with ${total} forces to ${territory(to).name}.`, ); if (sandmaster) { g.spice[sandmaster.key!] = sandmaster.before - 1; p.spice++; log(g, `${p.name}'s Sandmaster collected 1 spice on arriving in ${territory(to).name} by worm. ${sandmaster.before - 1} spice remains in that pile. This ride does not consume normal movement.`, { faction: p.faction, name: 'Sandmaster collection' }); } const intruded = intrusion(g, p, to, { wormRide: true }); if ( openTerritoryEntry( g, p, to, sector, total, totalElite, 'wormRide', 'wormRide', ) || intruded ) return g; } else log(g, `${p.name} declined the sandworm ride.`); nextWormRide(g); } else { requireRule( Array.isArray(action.discard) && action.discard.every( (card) => typeof card === 'string' && decision.cards.includes(card), ), 'You may discard only cards played in this battle.', ); const selected = new Set(action.discard); const context = selected.size ? cleanupBattleContext(g, decision.territory, p.id) : null; const discarded = [...selected].map((id) => ({ card: discard(g, p, id, cleanupDiscardRole(g, p, id)), discardedBy: p.id, publicFace: true, })); log( g, `${p.name} discarded ${selected.size} of their played battle cards.`, ); if (discarded.length) stageTreacheryDiscard(g, 'battle:winner', discarded, { kind: 'battleCleanup', event: context!.event, combatants: [...g.lastBattle], territory: decision.territory, player: p.id, source: 'winner', kept: decision.cards.filter((id) => !selected.has(id)), played: [...decision.cards], }); else finishBattle(g); } return g; } if (g.status === 'lobby') { if (t === 'rules') { requireRule(id === g.host, 'Only the host can change the rules mode.'); requireRule(typeof action.advanced === 'boolean' && Object.keys(action).every(key => key === 'type' || key === 'advanced'), 'Choose Basic or Advanced preview rules.'); if (g.advanced === action.advanced) return g; if (action.advanced) { const blocked = baseStartProfileBlock(g); requireRule(!blocked, blocked ?? 'This configuration cannot use Advanced preview.'); } const removedStrongholds = !action.advanced && !!g.strongholdCards; if (removedStrongholds) { requireRule(JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards()), 'Only unused lobby Stronghold Cards can be cleared when choosing Basic rules.'); g.strongholdCards = null; } g.advanced = action.advanced; g.players.forEach(player => (player.ready = !!player.bot)); log(g, `${p.name} selected ${g.advanced ? 'Advanced preview' : 'Basic'} rules.${removedStrongholds ? ' Stronghold Cards were disabled because they require Advanced rules.' : ''} Human readiness was cleared; AI seats remain ready.`); return g; } if (t === 'configureBot') { const positions = normalizedPlayerPositions(g); const quote = quoteLobbyBotConfiguration( { status: g.status, host: g.host, players: g.players, expansions: g.expansions, positions, }, id, action, ); if (!quote.ok) requireRule(false, quote.reason); if (!quote.changed) return g; const configuration = quote.configuration; const bot = getPlayer(g, configuration.target); const previous = { name: bot.name, difficulty: bot.bot!, faction: bot.faction, position: positions[bot.id], }; bot.bot = configuration.difficulty; if (bot.faction !== configuration.faction) { bot.faction = configuration.faction; bot.name = `${faction(bot.faction).name} AI`; bot.elites = bot.faction === 'ixians' ? { reserves: 7, tanks: 0, forces: {}, revived: 0 } : undefined; bot.leaders = leaders(bot.faction); } positions[bot.id] = configuration.position; g.playerPositions = positions; g.players.forEach((player) => (player.ready = !!player.bot)); const changes: string[] = []; if (previous.difficulty !== bot.bot) changes.push(`difficulty ${previous.difficulty} to ${bot.bot}`); if (previous.faction !== bot.faction) changes.push( `faction ${faction(previous.faction).name} to ${faction(bot.faction).name}`, ); if (previous.position !== configuration.position) changes.push( `player circle ${previous.position} to ${configuration.position}`, ); log( g, `${p.name} configured ${previous.name}: ${changes.join('; ')}. Human readiness was cleared; AI seats remain ready.`, ); return g; } if (t === 'seatPosition') { requireRule( action.target === undefined || action.target === id, 'You may choose only your own player circle.', ); const position = integer(action.position, 1, 6, 'Player circle'); const positions = normalizedPlayerPositions(g); requireRule( !g.players.some( (other) => other.id !== id && positions[other.id] === position, ), 'That player circle is occupied.', ); requireRule( positions[id] !== position, 'You already occupy that player circle.', ); positions[id] = position; g.playerPositions = positions; g.players.forEach((player) => (player.ready = !!player.bot)); log(g, `${p.name} chose player circle ${position}.`); return g; } if (t === 'techTokens') { requireRule(id === g.host, 'Only the host can change optional rules.'); requireRule( typeof action.enabled === 'boolean', 'Choose whether to use tech tokens.', ); g.techTokens = action.enabled ? createTechTokens() : null; g.players.forEach((p) => (p.ready = !!p.bot)); return g; } if (t === 'homeworlds') { requireRule(id === g.host, 'Only the host can change optional rules.'); requireRule( typeof action.enabled === 'boolean', 'Choose whether to use Homeworlds.', ); g.homeworlds = action.enabled ? { custody: null } : null; g.players.forEach((player) => (player.ready = !!player.bot)); return g; } if (t === 'strongholdCards') { requireRule(id === g.host, 'Only the host can change optional rules.'); requireRule( typeof action.enabled === 'boolean' && (!action.enabled || g.advanced), 'Stronghold Cards require Advanced rules.', ); g.strongholdCards = action.enabled ? createStrongholdCards() : null; g.players.forEach((player) => (player.ready = !!player.bot)); return g; } if (t === 'addBot' || t === 'removeBot') { requireRule(id === g.host, 'Only the host can manage AI seats.'); if (t === 'addBot') { const difficulty = stringField(action.difficulty); requireRule( ['Easy', 'Medium', 'Hard', 'Brutal'].includes(difficulty), 'Choose an AI difficulty.', ); const f = stringField(action.faction) as FactionId; requireRule( FACTIONS.some((x) => x.id === f), 'Unknown faction.', ); const bot = newPlayer(crypto.randomUUID(), `${faction(f).name} AI`, f); bot.bot = difficulty as NonNullable; joinGame(g, bot); } else { const target = getPlayer(g, stringField(action.target)); requireRule(target.bot, 'Only an AI seat can be removed here.'); g.players = g.players.filter((p) => p.id !== target.id); if (g.playerPositions) delete g.playerPositions[target.id]; g.players.forEach((p) => (p.ready = false)); } g.players.filter((p) => p.bot).forEach((p) => (p.ready = true)); return g; } if (t === 'ready') { p.ready = !p.ready; return g; } if (t === 'start') { requireRule(id === g.host, 'Only the host can start the game.'); requireRule(Object.keys(action).every(key => key === 'type' || key === 'advancedPreview') && (action.advancedPreview === undefined || action.advancedPreview === true), 'Start this table, or explicitly choose Advanced preview.'); start(g, action.advancedPreview === true); return g; } if (t === 'faction') { const f = action.faction as FactionId; requireRule( FACTIONS.some((x) => x.id === f), 'Unknown faction.', ); requireRule( !g.players.some((x) => x.id !== id && x.faction === f), 'That faction is taken.', ); requireRule( faction(f).expansion === 'base' || g.expansions.includes(faction(f).expansion), 'That expansion is disabled.', ); p.faction = f; p.elites = f === 'ixians' ? { reserves: 7, tanks: 0, forces: {}, revived: 0 } : undefined; p.leaders = leaders(f); g.players.forEach((x) => (x.ready = !!x.bot)); return g; } throw new RuleError('That action is not available in the lobby.'); } if (g.status === 'setup') { if (g.setupStage) { const expected = g.setupStage === 'prediction' ? ['predict'] : g.setupStage === 'traitors' ? ['traitor'] : g.setupStage === 'leaderSkills' ? ['leaderSkill'] : ['fremenSetup', 'ecazSetup', 'advisorSetup']; requireRule( expected.includes(t) && setupPending(g).includes(id), `Wait for the current setup step: ${g.setupStage}.`, ); } if (t === 'leaderSkill') { requireRule(Object.keys(action).every((key) => ['type','event','skill','leader'].includes(key)), 'Choose only your dealt skill and its eligible leader.'); const skill = stringField(action.skill), leader = stringField(action.leader); const unavailable = leaderSkillAssignmentUnavailable(g, id, skill); requireRule(!unavailable, unavailable ?? 'This Leader Skill is unavailable.'); g.leaderSkills = skillRule(() => chooseLeaderSkill(g.leaderSkills!, id, stringField(action.event), skill, leader, skillEligibleLeaders(g, id), random)); log(g, `${p.name} assigned ${leaderSkillCard(skill as Parameters[0]).name} to ${p.leaders.find((l) => l.id === leader)!.name}.`, { faction: p.faction, name: 'Leader Skill' }); } else if (t === 'advisorSetup') { requireRule( g.advanced && p.faction === 'beneGesserit' && !p.advisorSetup, 'Advisor setup is not available.', ); requireRule( !byFaction(g, 'fremen') || byFaction(g, 'fremen')!.reserves === 10, 'Wait for Fremen placement.', ); const to = stringField(action.territory); const sector = integer(action.sector, 0, 18, 'Sector'); requireRule( to !== MOBILE_STRONGHOLD && validGameLocation(g, to, sector), 'Choose a printed board sector; the mobile stronghold is not yet placed.', ); requireRule( p.reserves === 20, 'Advisor setup requires the unplaced starting force.', ); p.reserves--; place(p, to, sector, 1); p.advisors = { [to]: {} }; p.advisorSetup = true; settleAdvisors(g); } else if (t === 'traitor') { requireRule( p.traitorChoices.includes(String(action.leader)), 'Choose one of your dealt traitors.', ); if (g.setupStage === 'traitors') (g.traitorReserve ??= []).push( ...p.traitorChoices.filter( (leader) => leader !== String(action.leader), ), ); p.traitors = [String(action.leader)]; p.traitorChoices = []; } else if (t === 'ecazSetup') { requireRule(g.setupStage === 'forces' && p.faction === 'ecaz' && p.reserves === 20 && Object.keys(p.forces).length === 0 && Object.keys(action).every(key => ['type', 'placements'].includes(key)), 'Place Ecaz starting forces once during the current force setup.'); const placements = nexusRule(() => quoteEcazStartingForces(action.placements)); for (const [key, amount] of Object.entries(placements)) { const loc = splitLocation(key); place(p, loc.territory, loc.sector, amount); } p.reserves -= ECAZ_START_FORCES; log(g, `${p.name} placed six starting forces in Imperial Basin and retained fourteen in reserves.`, { faction: p.faction, name: 'Starting forces' }); } else if (t === 'fremenSetup') { requireRule( p.faction === 'fremen' && p.reserves === 20, 'Fremen setup is already complete.', ); requireRule( action.placements && typeof action.placements === 'object' && !Array.isArray(action.placements), 'Choose starting force placements.', ); const placements = Object.entries(action.placements).map( ([key, value]) => { const loc = key.includes(':') ? splitLocation(key) : { territory: key, sector: FREMEN_START.includes(key) ? territory(key).sectors[0] : -1, }; requireRule( FREMEN_START.includes(loc.territory) && validGameLocation(g, loc.territory, loc.sector), 'Starting forces belong in sectors of Sietch Tabr, False Wall South or False Wall West.', ); return { ...loc, amount: integer(value, 0, 10, 'Starting forces') }; }, ); requireRule( placements.reduce((sum, p) => sum + p.amount, 0) === 10, 'Place exactly ten starting forces.', ); let totalElite = 0; for (const loc of placements) { const key = location(loc.territory, loc.sector); const elite = integer( (action.elitePlacements as Record | undefined)?.[ key ] ?? 0, 0, Math.min(loc.amount, p.elites?.reserves ?? 0), 'Elite forces', ); totalElite += elite; if (loc.amount) place(p, loc.territory, loc.sector, loc.amount, elite); } requireRule( totalElite <= (p.elites?.reserves ?? 0), 'Too many elite forces in starting placements.', ); if (p.elites) p.elites.reserves -= totalElite; p.reserves = 10; } else if (t === 'predict') { requireRule( p.faction === 'beneGesserit' && !p.prediction, 'Prediction is already locked.', ); requireRule( g.players.some((x) => x.faction === action.faction && x.id !== id), 'Predict another faction in this game.', ); p.prediction = { faction: action.faction as FactionId, turn: integer(action.turn, 1, 10, 'Turn'), }; } else throw new RuleError('Complete the starting choices.'); return g; } if (t === 'replaceFaceDancer') { requireRule( g.phase === 8 && p.faction === 'tleilaxu', 'Replace a Face Dancer during Mentat Pause.', ); requireRule( p.faceDancerReplacedTurn !== g.turn, 'Only one Face Dancer replacement is permitted per turn.', ); const dancer = p.faceDancers?.find( (c) => !c.revealed && c.leader === action.leader, ); requireRule(dancer, 'Choose one of your unrevealed Face Dancers.'); p.faceDancerReplacedTurn = g.turn; g.response = { kind: 'faceDancerReplacement', owner: id, passed: [], intent: dancer.leader, }; return g; } if (t === 'choamInflation') { requireRule( g.phase === 8 && p.faction === 'choam', 'CHOAM places Inflation during Mentat Pause.', ); requireRule( !g.inflationUsed && !g.inflation, 'The Inflation token is unavailable or already used.', ); requireRule( g.inflationAttemptTurn !== g.turn, 'Inflation placement was already attempted this Mentat Pause.', ); requireRule( action.side === 'double' || action.side === 'cancel', 'Choose Double or Cancel for next turn’s charity.', ); g.inflationAttemptTurn = g.turn; g.response = { kind: 'choamInflation', owner: id, intent: action.side, passed: [], }; g.ready = []; return g; } if (t === 'bribe') { const blocked = bribeTimingBlock(g); requireRule(!blocked, blocked ?? 'Bribes are unavailable.'); const target = getPlayer(g, stringField(action.target)); requireRule( target.id !== id && target.id !== p.ally, 'Bribes are between different, non-allied factions.', ); const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice'); checkBureaucratContributors(g,target.id,[{payer:p.id,amount:n}]); p.spice -= n; log( g, `${p.name} paid a ${n}-spice bribe to ${target.name}; its recipient share becomes spendable at the next Mentat Pause.`, ); const source = stampBureaucratPayment(g,'bribe',p.id,target.id,n).bureaucratPayment; if (!offerBureaucratPayment(g,source,{kind:'bribe'})) target.bribes += n; return g; } if (t === 'stormDial') { requireRule( g.phase === 0 && g.stormPending === null && g.stormDialers.includes(id), 'You are not dialing the storm.', ); requireRule( g.stormDials[id] === undefined, 'Your storm dial is already locked.', ); g.stormDials[id] = integer( action.amount, g.turn === 1 ? 0 : 1, g.turn === 1 ? 20 : 3, 'Storm dial', ); if (g.stormDialers.every((x) => g.stormDials[x] !== undefined)) { g.stormPending = Object.values(g.stormDials).reduce((a, b) => a + b, 0); g.stormMovementSource = nexusRule(() => createStormSource(g.turn,'dials',g.stormPending!)); g.ready = []; log( g, `Storm distance revealed: ${g.stormPending} sectors. Resolve storm cards before moving.`, ); } return g; } if (t === 'alliance') { requireRule( g.phase === 1 && g.nexus && !g.spiceWindow && !g.spiceResolution, 'Alliances change only during a Nexus.', ); const target = action.target ? stringField(action.target) : null; requireRule(!atomicsAllianceChangeBlocked(g.moritaniAtomics, id, target) || (target === null && !p.ally), 'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.'); const formerAlly = p.ally ? getPlayer(g, p.ally).name : null; const formerOffer = g.allianceOffers[id]; const formerOfferName = g.players.find((player) => player.id === formerOffer)?.name; let other: Player | null = null; if (target !== null) { other = getPlayer(g, target); requireRule(other.id !== id, 'Choose another player.'); const blocked = homeworldAllianceReason(g, id, other.id); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); } const quote = nexusRule(() => quoteNexusAlliance( { players: g.players.map((player) => ({ id: player.id, ally: player.ally, })), offers: g.allianceOffers, actor: id, target, }, ), ); if (g.moritaniAtomics) { const moritani = byFaction(g, 'moritani')!; requireRule(quote.allies[moritani.id] === moritani.ally, 'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.'); } for (const player of g.players) player.ally = quote.allies[player.id]; g.allianceOffers = quote.offers; if (target === null) { log(g, formerAlly ? `${p.name} broke the alliance with ${formerAlly}; both are unallied.` : formerOffer ? `${p.name} withdrew the alliance offer to ${formerOfferName ?? 'a player no longer seated'} and remains unallied.` : `${p.name} is unallied.`); } else if (quote.formed) { requireRule(other, 'The alliance partner is no longer seated.'); p.allySinceTurn = other.allySinceTurn = g.turn; discardAllianceNexusCards(g, p, other); log(g, `${p.name} and ${other.name} formed an alliance.`); } else { log(g, `${p.name} offered an alliance to ${other!.name}; both players must agree before an alliance forms.`); } g.ready = []; return g; } if (t === 'charity') { const advancedBG = g.advanced && p.faction === 'beneGesserit'; const quote = charityQuote(g, p); const amount = quote.total; requireRule( g.phase === 2 && amount > 0, 'Charity is available here when you have fewer than two spice.', ); requireRule( !byFaction(g, 'choam') || g.choamCharity?.turn === g.turn, 'Resolve CHOAM income before claiming charity.', ); requireRule( p.charityTurn !== g.turn, 'Charity may be claimed only once per turn.', ); p.charityTurn = g.turn; if (advancedBG && p.spice >= 2) g.response = { kind: 'bgCharity', owner: id, passed: [], amount, ...(g.homeworlds ? { charityHomeworld: quote.homeworld } : {}), }; else payCharity(g, p, amount, quote.homeworld); log(g, `${p.name} claimed CHOAM charity.`); return g; } if (t === 'pledgeAid') { requireRule( ([3, 5].includes(g.phase) || (g.phase === 6 && g.advanced && p.faction === 'choam')) && p.ally, 'You may fund an ally during bidding/shipment, or as advanced CHOAM during Battle.', ); pledgeAid(g, p, action.amount); return g; } if (t === 'richeseBid') { const lot = g.richeseAuction; requireRule( g.phase === 3 && lot && !lot.outcome, 'There is no open Richese auction.', ); requireRule(p.hand.length < handLimit(p), 'Your hand is full.'); requireRule( lot.source !== 'blackMarket' || p.id !== lot.owner || action.amount === null || action.amount === 0, 'A positive Black Market self-bid is awaiting a ruling on payment and sale counting.', ); const amount = action.amount === null ? null : integer(action.amount, 0, Number.MAX_SAFE_INTEGER, 'Bid'); const ownAvailable = uncommittedSpice(g, p) + richeseOwnCommitment(lot, g.richeseFunding ?? {}, p.id); const allyAvailable = aidFor(g, p)?.amount ?? 0; const allyPayment = amount === null ? 0 : integer( action.allyPayment ?? Math.max(0, amount - ownAvailable), 0, Math.min(amount, allyAvailable), 'Ally payment', ); if (amount !== null) validateRicheseFunding(amount, allyPayment, ownAvailable, allyAvailable); if (amount !== null) { const parties = auctionBureaucratParties(g,p,amount,allyPayment,lot.owner); checkBureaucratContributors(g,parties.payee,parties.shares,allyPayment > 0); } g.richeseAuction = submitRicheseBid( lot, { event: stringField(action.event), actor: p.id, amount }, ownAvailable + allyAvailable, ); if (amount !== null) (g.richeseFunding ??= {})[p.id] = { amount, allyPayment, donor: allyPayment ? p.ally : null, }; g.active = g.richeseAuction.active; log( g, lot.method === 'silent' ? `${p.name} submitted a sealed bid; its amount remains concealed until every eligible bidder submits.` : `${p.name} ${amount === null ? 'passed' : `bid ${amount} spice`} in the ${lot.method === 'onceAround' ? 'Once Around' : 'Black Market'} auction.`, ); if (lot.method === 'silent' && g.richeseAuction.outcome) log( g, `Silent bids revealed together: ${g.richeseAuction.tieOrder.map((id) => `${getPlayer(g, id).name} ${g.richeseAuction!.sealed[id]}`).join('; ')}. Ties are resolved in storm order.`, ); settleRicheseLot(g); return g; } if (t === 'bid' || t === 'passBid') { requireRule( g.phase === 3 && g.auction?.active === id, 'Wait for your bid.', ); const a = g.auction; if (t === 'bid') { const quotedBid = quoteNormalAuctionBid({ currentBid: a.bid, amount: action.amount, maximum: karamaCard(g, p) ? Number.MAX_SAFE_INTEGER : p.spice + (aidFor(g, p)?.amount ?? 0), }); if (!quotedBid.ok) throw new RuleError(quotedBid.reason); a.bid = quotedBid.amount; a.allyPayment = a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0) ? contribution(g, p, a.bid, action.allyPayment) : 0; const parties = auctionBureaucratParties(g,p,a.bid,a.allyPayment); checkBureaucratContributors(g,parties.payee,parties.shares,a.allyPayment > 0); a.bidder = id; a.passed = []; } else if (!a.passed.includes(id)) a.passed.push(id); auctionNext(g); return g; } if (t === 'emperorGift' || t === 'emperorRevival') { requireRule( p.faction === 'emperor' && p.ally, 'Only the Emperor may use this power for an ally.', ); const recipient = getPlayer(g, p.ally); if (t === 'emperorRevival') { requireRule( !revivalPrevented(g, recipient.id), 'Tleilaxu prevented this faction’s normal revivals for this turn.', ); requireRule( g.phase === 4, 'Extra revivals are available during Revival.', ); } const maximum = t === 'emperorGift' ? uncommittedSpice(g, p) : Math.min( 3 - (g.emperorExtra[recipient.id] ?? 0), recipient.tanks, Math.floor(uncommittedSpice(g, p) / 2), ); const amount = integer( action.amount, 1, maximum, t === 'emperorGift' ? 'Spice' : 'Forces', ); const elite = t === 'emperorRevival' ? eliteChoice( amount, recipient.tanks, recipient.elites?.tanks ?? 0, action.elite, ) : 0; requireRule( elite <= eliteRevivalRemaining(recipient, g.advanced), 'Only one elite force may be revived per turn.', ); if (t === 'emperorRevival') requireRule( p.spice >= paidForceRevivalCost(recipient, amount, elite), 'Not enough spice for these extra revivals.', ); g.response = { kind: t, elite, owner: id, recipient: recipient.id, amount, passed: [], }; return g; } if (t === 'grantRevival') { requireRule( g.phase === 4 && p.faction === 'fremen' && p.ally, 'Only Fremen may grant their ally free revival during revival.', ); requireRule( !g.freeRevival.includes(p.ally), 'Free revival has already been granted.', ); requireRule( g.recruits?.turn !== g.turn, 'Grant Fremen ally revival before Recruits; changing the free rate after activation awaits a rules ruling.', ); g.freeRevival.push(p.ally); log( g, `${p.name} granted their ally up to three free force revivals this phase.`, ); return g; } if (t === 'tleilaxuRevivalLimit' || t === 'tleilaxuAllyDiscount') { requireRule( g.phase === 4 && p.faction === 'tleilaxu', 'Only Tleilaxu may grant revival benefits during Revival.', ); g.revivalRules ??= newRevivalRules(); if (t === 'tleilaxuRevivalLimit') { const target = getPlayer(g, stringField(action.target)); requireRule( target.id !== id && !g.revivalRules.limitBlocked, 'The expanded revival limit is unavailable.', ); requireRule( !g.revivalRules.expanded.includes(target.id), 'That faction already has permission to revive five forces.', ); g.revivalRules.expanded.push(target.id); log( g, `${p.name} allowed ${target.name} to revive up to five forces this phase.`, ); } else { requireRule( p.ally && !g.revivalRules.discountBlocked && g.revivalRules.allyDiscount !== p.ally, 'The allied revival discount is unavailable or already granted.', ); g.revivalRules.allyDiscount = p.ally; log(g, `${p.name} offered their ally half-price revival this phase.`); } return g; } if ( t === 'requestLeaderRevival' || t === 'quoteLeaderRevival' || t === 'declineLeaderRevival' || t === 'cancelLeaderRequest' || t === 'acceptLeaderRevival' ) { const tleilaxu = byFaction(g, 'tleilaxu'); requireRule( g.phase === 4 && tleilaxu, 'Tleilaxu leader negotiations require the Revival phase.', ); g.revivalRequests ??= {}; if (t === 'cancelLeaderRequest') { delete g.revivalRequests[id]; return g; } if (t === 'quoteLeaderRevival' || t === 'declineLeaderRevival') { requireRule( p.id === tleilaxu.id, 'Only Tleilaxu may quote a revival price.', ); const recipient = getPlayer(g, stringField(action.target)).id; const request = g.revivalRequests[recipient]; requireRule(request, 'Wait for that faction to request a leader.'); if (t === 'declineLeaderRevival') request.declined = true; else { request.price = integer(action.amount, 0, 1000000, 'Price'); delete request.declined; } return g; } requireRule( p.id !== tleilaxu.id && !p.leaderRevived, 'Only one negotiated leader revival is permitted per faction per phase.', ); const requested = t === 'requestLeaderRevival' ? stringField(action.leader) : g.revivalRequests[id]?.leader; requireRule(requested, 'Request a leader first.'); requireRule( !revivalPrevented(g, p.id), 'Tleilaxu prevented this faction’s normal revivals for this turn.', ); const buyback = p.leaders.some( (l) => l.id === requested && l.gholaBy === tleilaxu.id, ); requireRule( buyback || p.leaders.some(nativeAvailable), 'When all native leaders are unavailable, use ordinary revival rules.', ); if (requested === 'kwisatz') requireRule( g.advanced && p.faction === 'atreides' && p.kwisatz?.dead, 'Kwisatz Haderach is not in the tanks.', ); else requireRule( p.leaders.some((l) => l.id === requested && l.dead && !l.capturedBy), 'Choose one of your dead native leaders.', ); requireRule( !g.revivalRules?.earlyBlocked.includes(`${p.id}:${requested}`), 'Early revival of that leader was canceled this phase.', ); if (t === 'requestLeaderRevival') g.revivalRequests[id] = { leader: requested, price: null }; else { const price = g.revivalRequests[id]?.price; requireRule( !g.revivalRequests[id]?.declined, 'That request was declined.', ); requireRule( typeof price === 'number', 'Wait for Tleilaxu to quote a price.', ); requireRule(p.spice >= price, 'Not enough spice for the agreed revival.'); beginRevival(g, { player: id, kind: requested === 'kwisatz' ? 'kwisatz' : 'leader', leader: requested, normalCost: price, cost: price, free: 0, checks: ['earlyRevival'], }); } return g; } if (t === 'reviveForeignGhola') { requireRule( g.advanced && g.phase === 4 && p.faction === 'tleilaxu', 'Foreign gholas require advanced Tleilaxu during Revival.', ); requireRule(action.leader !== DUKE_VIDAL_ID, 'Only Ecaz may revive Duke Vidal, including with Ghola.'); const leader = g.players .flatMap((owner) => owner.leaders) .find((l) => l.id === action.leader); requireRule( leader && leader.faction !== p.faction && leader.dead && !leader.capturedBy, 'Choose a dead leader of another faction.', ); requireRule( !isAuditorLeader(leader), 'The Auditor cannot be acquired as a Tleilaxu foreign ghola.', ); requireRule( controlledLeaders(g, p).filter((l) => !l.dead).length < 5, 'Foreign gholas may fill your active leader pool only up to five.', ); requireRule( p.gholaBlocked?.[leader.id] !== g.turn, 'Reviving that foreign ghola was canceled this turn.', ); const normalCost = leader.strength; const cost = revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost; requireRule(p.spice >= cost, 'Not enough spice for this ghola.'); beginRevival(g, { player: id, kind: 'foreignGhola', leader: leader.id, normalCost, cost, free: 0, checks: cost < normalCost ? ['foreignGhola', 'revivalDiscount'] : ['foreignGhola'], }); return g; } if (t === 'revive') { requireRule(g.phase === 4, 'Revival is not the current phase.'); const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces'); const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite); requireRule( elite <= eliteRevivalRemaining(p, g.advanced), 'Only one elite force may be revived per turn.', ); const availableFree = forceRevivalQuote(g, p, n).free; const freeElite = p.faction === 'ixians' ? integer( action.freeElite ?? Math.min(availableFree, elite), Math.max(0, availableFree - (n - elite)), Math.min(availableFree, elite), 'Free cyborg revivals', ) : 0; const quote = forceRevivalQuote(g, p, n, elite, freeElite); requireRule(quote.cost <= p.spice, 'Not enough spice for revival.'); const checks: PendingRevival['checks'] = []; const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked; const uncancelableLimit = g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p)); if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit)) checks.push('choamRevival'); if (!choamBenefit && p.revived + n > uncancelableLimit) checks.push('revivalLimit'); const factionCost = choamBenefit ? n - quote.free : quote.normalCost; if (quote.cost < factionCost) checks.push('revivalDiscount'); beginRevival(g, { player: id, kind: 'forces', amount: n, elite, ...quote, checks, }); return g; } if (t === 'reviveKwisatz' || t === 'reviveLeader') { requireRule( g.phase === 4 && (!p.leaderRevived || p.faction === 'tleilaxu'), 'Only one leader revival is permitted this phase.', ); requireRule( !revivalPrevented(g, p.id), 'Tleilaxu prevented this faction’s normal revivals for this turn.', ); const options = leaderRevivalOptions(g, p); const duke = t === 'reviveLeader' && action.leader === DUKE_VIDAL_ID; if (duke) { requireRule(p.faction === 'ecaz', 'Only Ecaz may revive Duke Vidal.'); const blocked = ecazDukeRevivalBlock(g, p.id); requireRule(!blocked, blocked ?? 'Duke Vidal is unavailable for revival.'); } const dukeQuote = duke ? quoteEcazDukeRevival(g, id, !!revivalDiscount(g, p)) : null; const l = t === 'reviveLeader' ? duke ? g.dukeVidal!.leader : p.leaders.find((l) => l.id === action.leader) : undefined; if (t === 'reviveKwisatz') requireRule( g.advanced && p.faction === 'atreides' && p.kwisatz?.dead, 'Kwisatz Haderach is not in the tanks.', ); else requireRule( l?.dead && !l.capturedBy && !l.gholaBy, 'Choose a dead leader.', ); const option = options.leaders.find((option) => option.id === l?.id); const normal = t === 'reviveKwisatz' ? !!options.kwisatz : !!option && !option.early; requireRule( normal || p.faction === 'tleilaxu', 'All leaders must die before a new revival cycle can begin.', ); const checks: PendingRevival['checks'] = []; if (!normal) { requireRule( !g.revivalRules?.earlyBlocked.includes(`${id}:${l!.id}`), 'Early revival of that leader was canceled this phase.', ); checks.push('earlyRevival'); } requireRule( t === 'reviveKwisatz' ? !!options.kwisatz : !!option, 'That leader is unavailable for revival this phase.', ); const normalCost = dukeQuote?.normalCost ?? l?.strength ?? 2; const cost = dukeQuote?.cost ?? (revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost); requireRule(p.spice >= cost, 'Not enough spice.'); if (cost < normalCost) checks.push('revivalDiscount'); // Duke may be revived independently even while later six-disc native // cycles are gated; that request must not erase an opened first cohort. if (!duke || options.cycle > p.revivalCycle) p.revivalCycle = options.cycle; beginRevival(g, { player: id, kind: t === 'reviveKwisatz' ? 'kwisatz' : 'leader', leader: l?.id, normalCost, cost, free: 0, checks, }); return g; } if (t === 'revealNoField') { requireRule( p.faction === 'richese', 'Only Richese controls the concealed token.', ); const blocked = noFieldRevealBlock(g, p); requireRule(!blocked, blocked ?? 'Voluntary reveal is unavailable.'); requireRule( action.event === p.noFieldEvent && action.token === p.noField?.deployed?.tokenId, 'This No-Field reveal selection is stale.', ); revealPlayerNoField(g, p, 'voluntary'); return g; } if ( [ 'ship', 'homeworldShip', 'guildHomeworldShip', 'junctionShip', 'move', 'emperorHomeworldMove', 'endMovement', 'guildShip', ].includes(t) ) { const richese = byFaction(g, 'richese'); requireRule( !richese || noFieldAllyOfferBlock(g, richese) || (g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === id), 'Wait for Richese to offer or pass its allied shipment opportunity.', ); } if (t === 'offerRicheseNoField') { const blocked = noFieldAllyOfferBlock(g, p); requireRule( !blocked, blocked ?? 'Allied No-Field shipment is unavailable.', ); requireRule( action.event === p.noFieldEvent, 'This No-Field token selection is stale.', ); const offer: RicheseAllyOffer = { event: crypto.randomUUID(), owner: id, recipient: p.ally!, tokenId: stringField(action.token), tokenEvent: p.noFieldEvent!, territory: stringField(action.territory), sector: integer(action.sector, 0, 18, 'Sector'), payer: stringField(action.payer), }; alliedNoFieldQuote(g, offer, offer.recipient === id); g.richeseAllyOffer = offer; g.richeseAllyOpportunity = { turn: g.turn, recipient: offer.recipient }; g.decision = { kind: 'richeseAllyShipment', player: offer.recipient, owner: id, }; g.ready = []; log( g, `${p.name} offered their ally a No-Field shipment. Its proposed token and destination remain private until shipment is declared.`, ); return g; } if (t === 'offerJunctionTransport') { const option = junctionTransportWindow(g); requireRule(option && option.owner === id && option.canOffer, option?.blocked ?? 'Only high-population Junction can offer transport during another faction’s unused shipment.'); requireRule(action.event === option.offerEvent && (action.rate === 'half' || action.rate === 'full') && Object.keys(action).every((key) => ['type', 'event', 'rate'].includes(key)), 'Choose half or full price for the current Junction opportunity.'); g.junctionOffer = {event: crypto.randomUUID(), turn: g.turn, owner: id, recipient: option.recipient, rate: action.rate}; log(g, `${p.name} offered ${getPlayer(g, option.recipient).name} ${action.rate}-price Junction transport for their current shipment. They may use the offer or choose their ordinary actions.`); return g; } if (t === 'junctionShip') { performJunctionTransport(g, p, action); return g; } if (t === 'homeworldShip' || t === 'guildHomeworldShip') { const guildSecretEvent = action.nexus === undefined ? undefined : validateGuildSecretDeclaration(g,id,action.nexus); requireRule(!guildSecretEvent || t === 'homeworldShip', 'Guild Secret Ally does not grant arbitrary Arrakis-to-Homeworld routes.'); const route = t === 'guildHomeworldShip' ? 'arrakis' as const : undefined; const blocked = route ? guildHomeworldShipmentBlock(g, p) : homeworldShipmentBlock(g, p); requireRule(!blocked, blocked ?? 'Homeworld shipment is unavailable.'); requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment', ...(guildSecretEvent ? ['nexus'] : [])].includes(key)), 'Choose explicit typed source groups and a Homeworld destination without concealed tokens.'); requireRule(action.event === homeworldShipmentEvent(g, p, route), 'This Homeworld source selection is stale.'); declareHomeworldShipment(g, p, {player: id, destination: stringField(action.destination), sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {}), ...(guildSecretEvent ? {guildSecretEvent} : {})}, action.allyPayment); return g; } if (t === 'ship') { requireRule( g.phase === 5 && g.active === id && shipmentAvailable(g,p), 'You may ship once, before your movement.', ); let nexusEvent: string | undefined; let guildSecretEvent: string | undefined; let richesePair: PendingShipment['richesePair']; if (action.revealedToken !== undefined) { const offer = nexusRicheseCunningOffer(g, id); requireRule(offer && !offer.blocked && action.nexus === offer.event, offer?.blocked ?? 'Richese Cunning is unavailable or stale.'); requireRule(Object.keys(action).sort().join(',') === 'allyPayment,event,nexus,noField,revealedToken,sector,territory,type', 'Choose exactly two physical No-Field tokens and a shipment destination.'); richesePair = { event: offer.event, revealedTokenId: stringField(action.revealedToken) }; } else if (action.nexus !== undefined && action.nexus === nexusGuildSecretAllyOffer(g,id)?.event) { guildSecretEvent = validateGuildSecretDeclaration(g,id,action.nexus); requireRule(Object.keys(action).every(k => ['type','territory','sector','amount','elite','homeworldSources','allyPayment','nexus'].includes(k)), 'Guild Secret Ally needs an ordinary physical reserve shipment.'); } else if (action.nexus !== undefined) { const offer = nexusRicheseOffer(g,id); requireRule(offer && !offer.blocked && action.nexus === offer.event, offer?.blocked ?? 'This Richese Secret Ally shipment is unavailable or stale.'); requireRule(Object.keys(action).every(k => ['type','territory','sector','amount','elite','homeworldSources','allyPayment','nexus'].includes(k)), 'Richese Secret Ally requires an ordinary physical reserve shipment.'); nexusEvent = offer.event; } checkShipmentPromises(g, p, { territory: String(action.territory), amount: richesePair ? 1 : Number(action.amount), }); const to = String(action.territory), s = integer(action.sector, 0, 18, 'Sector'); const advisors = arrivalAsAdvisor(g, p, to); requireRule( to !== MOBILE_STRONGHOLD || p.faction === 'ixians', 'Only Ixians may ship directly into the mobile stronghold.', ); allowedEntry(g, p, to, s, !guildSecretEvent && g.advanced && p.faction === 'fremen', advisors); let noField: PendingShipment['noField']; if (action.noField !== undefined) { requireRule( p.faction === 'richese', 'Only Richese may declare its own concealed No-Field shipment.', ); const blocked = noFieldShipBlock(g, p); requireRule(!blocked, blocked ?? 'No-Field shipment is unavailable.'); requireRule( action.event === p.noFieldEvent, 'This No-Field selection is stale.', ); const tokenId = stringField(action.noField); if (richesePair) noFieldRule(() => validateRicheseNoFieldPair(p.noField!, { tokenId, controller: id, location: { territory: to, sector: s }, }, richesePair.revealedTokenId, p.reserves)); else noFieldRule(() => deployRicheseNoField(p.noField!, { tokenId, controller: id, location: { territory: to, sector: s }, })); requireRule( action.amount === undefined || action.amount === 1, 'The shipment price is for one No-Field, not a chosen number of physical forces.', ); requireRule( action.elite === undefined || action.elite === 0, 'A concealed No-Field is not an elite force.', ); noField = { tokenId, event: p.noFieldEvent! }; } requireRule(!richesePair || noField, 'Richese Cunning needs a concealed native No-Field.'); const n = noField ? 1 : integer(action.amount, 1, p.reserves, 'Forces'); const elite = noField ? 0 : eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, action.elite); const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)}; requireRule(action.smuggler === undefined || typeof action.smuggler === 'boolean', 'Choose whether to use Smuggler.'); const smuggler = action.smuggler === true && !noField && !guildSecretEvent && !nexusEvent ? quoteSmugglerShipment(g, id, to, n) : null; const smugglerCompanion = action.smuggler === true && noField && !guildSecretEvent && !nexusEvent ? quoteSmugglerNoField(g, id, to) : null; requireRule(action.smuggler !== true || smuggler || smugglerCompanion, 'Smuggler needs a living face-up native trainer and an empty territory, with two ordinary shipment forces or one reserve companion beside your No-Field.'); const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(to).type,n)).cost : nexusEvent ? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost : reserveShipmentCost(price,territory(to).type,n - (smuggler ? 1 : 0)); if (p.faction === 'fremen' && !guildSecretEvent) { requireRule( territory('the_great_flat').sectors.some((fs) => territory(to).sectors.some( (ts) => distance(location('the_great_flat', fs), location(to, ts)) <= 2, ), ), 'Fremen reinforcements must arrive within two territories of the Great Flat.', ); } requireRule( cost <= p.spice + (aidFor(g, p)?.amount ?? 0), 'Not enough spice to ship those forces.', ); const allyPayment = contribution(g, p, cost, action.allyPayment); const shipment: PendingShipment = { ...(smuggler ? { smuggler } : {}), ...(smugglerCompanion ? { smugglerCompanion } : {}), ...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}), ...(nexusEvent ? {nexusEvent} : {}), ...(guildSecretEvent ? {guildSecretEvent} : {}), ...(richesePair ? { richesePair } : {}), turn: g.turn, player: id, territory: to, sector: s, amount: n, elite, cost, allyPayment, advisors, ...(action.homeworldSources === undefined ? {} : { homeworldSources: action.homeworldSources as NativeReserveSelections, }), ...(noField ? { noField } : {}), }; if (noField && g.leaderSkills) shipment.noFieldSkillProof = noFieldSkillProof(shipment); if (noField && g.leaderSkills) validatePhysicalShipment(g, shipment); if (nexusEvent) recordNexusRicheseShipment(g,shipment); bindGuildCunningShipment(g,p,'reserve',shipment); if (shipment.noField) { // This cancellation window bypasses offerShipment, so reject known // unsupported arrival combinations before saving its private intent. validateShipmentArrival(g, shipment); checkShipmentIncomeRounding(g, p, cost, allyPayment); if (richesePair) recordRichesePairShipment(g, shipment); g.pendingShipment = shipment; g.response = { kind: 'richeseNoField', owner: p.id, passed: [], ...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}) }; log( g, `${p.name} declared a concealed No-Field shipment${richesePair ? ' with Richese Nexus Cunning and one faceup token' : ''}${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.${richesePair ? ' The Nexus card is spent; payment and both token effects await the responses.' : ''}`, ); } else offerShipment(g, shipment); return g; } if (t === 'guildShip') { const guildSecretEvent = action.nexus === undefined ? undefined : validateGuildSecretDeclaration(g,id,action.nexus); if (guildSecretEvent) requireRule(Object.keys(action).every(k => ['type','from','forces','eliteForces','amount','elite','territory','sector','allyPayment','nexus'].includes(k)), 'Guild Secret Ally cross-shipment needs explicit physical forces.'); const homeworldTarget = action.territory ?? 'reserves'; if (g.homeworlds?.custody && p.faction === 'guild' && (homeworldTarget === 'reserves' || (typeof homeworldTarget === 'string' && homeworldTarget.startsWith('homeworld:')))) { const blocked = guildHomeworldShipmentBlock(g, p); requireRule(!blocked, blocked ?? 'Guild Homeworld shipment is unavailable.'); requireRule(action.noField === undefined && (action.sector === undefined || action.sector === 0), 'A Homeworld arrival has no planet sector or concealed token.'); const group = forceGroup(g, p, action); const sources = Object.fromEntries(group.group.map(([key, amount]) => [key, {normal: amount - (group.eliteGroup[key] ?? 0), elite: group.eliteGroup[key] ?? 0}])); declareHomeworldShipment(g, p, {route: 'arrakis', player: id, destination: homeworldTarget === 'reserves' ? 'homeworld:guild' : homeworldTarget as string, sources}, action.allyPayment); return g; } checkShipmentPromises( g, p, action.from === 'reserves' ? { territory: String(action.territory), amount: Number(action.amount) } : null, ); requireRule( action.noField === undefined, 'Guild transport of a concealed No-Field awaits its specific return and custody rules.', ); requireRule( g.phase === 5 && g.active === id && shipmentAvailable(g,p) && (p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally || !!guildSecretEvent), 'Guild shipment is not available.', ); const fromReserves = action.from === 'reserves'; requireRule(!guildSecretEvent || !fromReserves, 'Use the reserve shipment selector for paid Guild Secret Ally reinforcements.'); requireRule( !fromReserves || p.faction === 'fremen', 'Only allied Fremen may cross-ship from their southern reserves.', ); const { group, eliteGroup, elite, origin, total: n, } = fromReserves ? { origin: 'reserves', group: [] as [string, number][], eliteGroup: {} as Record, elite: eliteChoice( Number(action.amount), p.reserves, p.elites?.reserves ?? 0, action.elite, ), total: integer(action.amount, 1, p.reserves, 'Forces'), } : forceGroup(g, p, action); requireRule( group.every(([key]) => splitLocation(key).sector !== g.storm), 'The source is in storm.', ); const to = stringField(action.territory ?? 'reserves'); const sector = integer(action.sector ?? 0, 0, 18); requireRule(fromReserves || to === 'reserves' || to !== origin, 'Cross-shipment requires a different destination territory.'); requireRule( to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent, 'Only the Guild may ship forces back to reserves.', ); requireRule(!guildSecretEvent || to !== 'reserves' || !g.homeworlds?.custody, 'Guild Secret Ally return to Homeworld reserves awaits its route ruling.'); const advisors = to !== 'reserves' && arrivalAsAdvisor(g, p, to); const sourceLock = p.advisors?.[origin]?.lockedTurn; requireRule( to === 'reserves' || advisors || sourceLock !== g.turn || !g.players.some((other) => other.id !== id && at(other, to)), 'New advisors cannot become fighters this turn.', ); requireRule( to !== MOBILE_STRONGHOLD || p.faction === 'ixians', 'Only Ixians may ship directly into the mobile stronghold.', ); if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors); const halfRate = g.karamaShipping?.player === id || !(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id); const cost = guildShipmentCost( to === 'reserves' ? 'reserves' : territory(to).type, n, halfRate); requireRule( p.spice + (aidFor(g, p)?.amount ?? 0) >= cost, 'Not enough spice.', ); const allyPayment = contribution(g, p, cost, action.allyPayment); checkShipmentIncomeRounding(g, p, cost, allyPayment); const frame: GuildTransportOrder = { turn: g.turn, player: id, origin, group, eliteGroup, to, sector, amount: n, elite, cost, allyPayment, advisors, }; if (!guildSecretEvent && openGuildTransportRate(g, p, frame, fromReserves)) return g; commitGuildTransport(g, p, frame, fromReserves, guildSecretEvent); return g; } if (t === 'emperorHomeworldMove') { const blocked = emperorHomeworldMoveBlock(g, p); requireRule(!blocked, blocked ?? 'Homeworld movement is unavailable.'); requireRule( Object.keys(action).every((key) => ['type', 'event', 'origin', 'normal', 'elite'].includes(key), ), 'Choose only the source Homeworld and physical force types.', ); requireRule( action.event === emperorHomeworldMoveEvent(g, p), 'This Homeworld movement selection is stale.', ); const quote = homeworldRule(() => quoteEmperorHomeworldMove( { ...homeworldContext(g), status: g.status, phase: g.phase, currentPlayer: g.active, movesLeft: movesAllowed(g, p) - p.moved, }, g.homeworlds!.custody!, id, { origin: stringField(action.origin) as EmperorHomeworld, forces: { normal: integer(action.normal, 0, 20, 'Normal forces'), elite: integer(action.elite, 0, 5, 'Sardaukar'), }, }, ), ); g.homeworlds!.custody = quote.state; observeOccupation(g); finishShipmentPromises(g, p, null); p.shipped = true; p.moved++; const name = (world: string) => world === 'homeworld:emperor' ? 'Kaitain' : 'Salusa Secundus'; const shifts = quote.populations.after .filter((world) => world.native === id) .map( (world) => `${name(world.location)} now has ${world.population} ${world.card === 'salusa_secundus' ? 'Sardaukar' : 'native reserves'} (${world.side} population)`, ) .join('; '); log( g, `${p.name} moved ${quote.forces.normal} normal forces and ${quote.forces.elite} Sardaukar from ${name(quote.origin)} to ${name(quote.destination)}. This spends one movement and no spice; total reserves are unchanged. ${shifts}.`, ); return g; } if (t === 'move') { requireRule(action.discoveryFlight === undefined, 'Choose a carried Discovery Ornithopter token; saved flight receipts are internal.'); if (action.noField !== undefined) { const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, id)); requireRule(!blocked, blocked ?? 'The No-Field cannot move.'); } if (!p.shipped) checkShipmentPromises(g, p, null); requireRule( g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p), 'Your movement is not available.', ); const skillLeader = planetologistLeader(g, id); requireRule(action.origins === undefined && (action.planetologist === undefined || (skillLeader && (action.planetologist === 'range' || action.planetologist === 'gather'))), 'Choose an available Planetologist movement mode.'); const planetologist: PlanetologistMovement | undefined = action.planetologist === undefined ? undefined : {leader: skillLeader!, mode: action.planetologist as PlanetologistMovement['mode']}; const { group, eliteGroup, elite, origin, origins, total: n, noField, sourceKeys, } = forceGroup(g, p, action, planetologist?.mode === 'gather' ? 2 : 1); const to = stringField(action.territory), s = integer(action.sector, 0, 18, 'Sector'); const {advisors, lockedTurn, wantsFighters} = movementStance(g, p, origins, to, action.fighters === true); requireRule( origins.every(source => !balisetPrevents(g, id, source, to)), 'Baliset prevents movement into this CHOAM territory; shipment remains possible.', ); allowedEntry(g, p, to, s, false, advisors && !wantsFighters); requireRule( sourceKeys.every((key) => key !== location(to, s)), 'Choose a different destination.', ); // Reject known unsupported arrivals before any movement-card event or // native movement-prevention window can commit this declaration. validateMovementArrival(g, { player: id, group, eliteGroup, elite, origin, origins, total: n, to, sector: s, advisors, wantsFighters, lockedTurn, }); let flight = g.ornithopter?.player === id ? g.ornithopter : null; if (action.movementCard !== undefined) { const mode = action.ornithopter; requireRule( mode === 'range3' || mode === 'twoGroups', 'Choose an Ornithopter movement mode.', ); const blocked = ornithopterBlock(g, p, mode); requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.'); const card = p.hand.find((c) => c.id === action.movementCard); requireRule( card && richeseCardDefinition(card)?.card.effect === 'ornithopter', 'Choose the canonical Ornithopter in your hand.', ); const reserved = transferCardBlock(g, p, card); requireRule(!reserved, reserved ?? 'The card is committed.'); requireRule( action.ornithopterEvent === undefined, 'A new card movement cannot name an old event.', ); flight = { event: crypto.randomUUID(), player: id, card, turn: g.turn, mode, startingMove: p.moved, completed: 0, }; g.ornithopter = flight; p.hand = p.hand.filter((c) => c.id !== card.id); log( g, `${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`, ); } else if (flight) requireRule( action.ornithopterEvent === flight.event, 'Use the current Ornithopter movement event.', ); else requireRule( action.ornithopterEvent === undefined && action.ornithopter === undefined, 'No matching Ornithopter movement is active.', ); requireRule( !flight || !isAdvisor(p, origin), 'Advanced advisor use of the Ornithopter card awaits its ruling.', ); const move: MovementOrder = { player: id, group, eliteGroup, elite, origin, ...(planetologist ? {planetologist} : {}), ...(planetologist?.mode === 'gather' ? {origins} : {}), total: n, to, sector: s, advisors, wantsFighters, lockedTurn, ...(noField ? { noField } : {}), ...(flight ? { ornithopterEvent: flight.event, ornithopterRange: flight.mode === 'range3', } : {}), }; if (action.discoveryOrnithopter !== undefined) move.discoveryFlight = nexusRule(() => quoteDiscoveryFlight(g, action.discoveryOrnithopter, move)); validatePlanetologistMove(g, p, move); const speed = movementOrderRange(g, p, move); if (action.sandmaster !== undefined) { move.sandmaster = nexusRule(() => quoteSandmasterMovement(g, id, sandmasterOrder(g, p, move), action.sandmaster)); validateSandmasterOrder(g, p, move); } requireRule( sourceKeys.every( (key) => gameDistance(g, key, location(to, s), (k) => pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)), ) <= movementOrderRange(g, p, move, splitLocation(key).territory), ), planetologist?.mode === 'gather' ? 'Each selected origin must have an unblocked route within its own movement range.' : `That destination is blocked or more than ${speed} territories away.`, ); validateFlightSelection(g, p, move); if ( !move.ornithopterRange && !move.discoveryFlight && p.faction === 'ixians' && elite > 0 && !(p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved) && !fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') && (move.sandmaster ? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1) : group.some(([key]) => selectedOriginElites(group, eliteGroup, splitLocation(key).territory) > 0 && gameDistance(g, key, location(to, s), (k) => pathBlocked(g, p, k, isAdvisor(p, splitLocation(key).territory)), ) > planetologistRange(1, planetologist?.mode))) ) { g.pendingIxMove = move; g.response = { kind: 'ixMovement', owner: id, location: location(to, s), amount: n, elite, passed: [], }; } else if ( !move.ornithopterRange && !move.discoveryFlight && p.faction === 'fremen' && p.fremenNexusMovementBlockedTurn !== g.turn && !(p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved) && !fighterCount(p, 'arrakeen') && !fighterCount(p, 'carthag') && (move.sandmaster ? Object.values(move.sandmaster.routes).some(route => sandmasterRouteDistance(g, route) > 1) : group.some( ([key]) => gameDistance(g, key, location(to, s), (k) => pathBlocked(g, p, k, isAdvisor(p, origin))) > planetologistRange(1, planetologist?.mode))) ) { g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move }; g.response = { kind: 'fremenMovement', ...(move.sandmaster ? {sandmasterProof: JSON.stringify(move.sandmaster)} : {}), owner: id, location: location(to, s), amount: n, passed: [], }; } else offerChoamMovement(g, move); return g; } if (t === 'nexusGuildSkipShipment') { const record = currentGuildCunning(g,id); requireRule(g.status === 'playing' && g.phase === 5 && g.active === id && record?.stage === 'secondShipment' && !record.shipment && action.event === record.receipt.event && Object.keys(action).every(k => ['type','event'].includes(k)), 'Only the current unused Guild Cunning shipment may be declined.'); finishShipmentPromises(g,p,null); record.shipment = {kind:'declined',stage:'declined'}; record.stage = 'extraMove'; saveGuildCunning(g,record); log(g,`${p.name} declined the second Guild shipment. No forces or spice moved; only an unused Hajr movement remains available.`); return g; } if (t === 'endMovement') { finishShipmentPromises(g, p, null); requireRule( g.phase === 5 && g.active === id, 'Wait for your movement turn.', ); if (action.nexus !== undefined) { declareGuildCunning(g,p,action.nexus); return g; } if (g.ornithopter?.player === id) finishOrnithopter(g, 'end'); else finishMovementTurn(g, id); return g; } if (t === 'chooseBattle') { requireRule( g.phase === 6 && g.active === id && !g.battle, 'Wait for your battle choice.', ); const choice = battles(g).find( (b) => b.chooser === id && b.territory === action.territory && (b.attacker === id ? b.defender : b.attacker) === action.target, ); requireRule(choice, 'Choose one of your unresolved battles.'); g.auditorInsight = null; retireNexusChoamInsight(g); const noFieldPlayers = [choice.attacker, choice.defender].filter( (playerId) => getPlayer(g, playerId).noField?.deployed?.location.territory === choice.territory, ); for (const playerId of noFieldPlayers) requireRule( Object.entries(getPlayer(g, playerId).forces).every( ([key, n]) => !n || splitLocation(key).territory !== choice.territory, ), 'Mixed ordinary-force and No-Field battle dialing awaits a ruling. Reveal voluntarily before Battle while that option is available.', ); const battleEvent = crypto.randomUUID(); g.battle = { ...choice, chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0), event: battleEvent, ...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}), ...(g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}), ...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 1 as const } : {}), ...(g.leaderSkills && ordinaryLeaderSkillModeSupported(g) ? { diplomatRetreatVersion: 1 as const } : {}), ...(g.ecazTreachery ? { harassAllocationVersion: 1 as const } : {}), ...(byFaction(g, 'richese') ? { preLeader: { event: battleEvent, ready: [], closed: false } } : {}), ...(noFieldPlayers.length ? { noFieldPlayers } : {}), prepared: true, plans: {}, revealed: false, traitorCalls: {}, }; createMentatQuestion(g); beginStrongholdBattle(g); log( g, `${p.name} chose the battle between ${getPlayer(g, choice.attacker).name} and ${getPlayer(g, choice.defender).name} in ${combatLocationName(g, choice.territory)}. ${getPlayer(g, choice.attacker).name} remains the aggressor.`, ); return g; } if (t === 'declineBattlePower') { const b = g.battle; requireRule( b?.preparation?.owner === id && !['prescienceAnswer', 'nexusPrescienceAnswer'].includes(b.preparation.kind), 'You do not own this battle power decision.', ); const kind = b.preparation.kind; if (kind === 'voice') battlePreparation(g, 'prescience'); else finishBattlePreparation(g); log( g, `${p.name} declined ${kind === 'voice' ? 'the Voice' : 'battle prescience'}.`, ); return g; } if (t === 'prescience') { const b = g.battle; requireRule( b?.preparation?.kind === 'prescience' && b.preparation.owner === id, 'Prescience is not available. The Voice decision must finish first.', ); requireRule( ['leader', 'weapon', 'defense', 'dial'].includes(String(action.field)), 'Choose a battle-plan element.', ); const beneficiary = b.preparation.beneficiary; const target = beneficiary === b.attacker ? b.defender : b.attacker; requireRule( action.field !== 'dial' || !b.noFieldPlayers?.includes(target), 'Atreides may not inspect the number dialed in a No-Field battle.', ); b.prescience = { player: beneficiary, field: action.field as PlanField }; b.preparation = { kind: 'prescienceAnswer', owner: beneficiary === b.attacker ? b.defender : b.attacker, beneficiary, }; g.response = { kind: 'prescience', owner: id, passed: [] }; return g; } if (t === 'nexusPrescienceAnswer') { const b = g.battle, record = b?.nexusInspection; requireRule(b?.preparation?.kind === 'nexusPrescienceAnswer' && b.preparation.owner === id && record?.stage === 'answer' && record.target === id && action.event === record.event && Object.keys(action).sort().join(',') === 'event,type,value', 'This Nexus answer does not match your current inspection.'); requireRule(record.mode !== 'cunning' || record.field !== 'dial' || !b.noFieldPlayers?.includes(id), 'Atreides may not inspect the number dialed in a No-Field battle.'); const value = record.field === 'dial' ? typeof action.value === 'number' ? action.value : NaN : action.value === null || action.value === '' ? null : stringField(action.value); answerCurrentNexusInspection(g, value); return g; } if (t === 'prescienceAnswer') { const b = g.battle; requireRule( b?.preparation?.kind === 'prescienceAnswer' && b.preparation.owner === id && b.prescience, 'You are not revealing a prescience element.', ); requireRule( b.prescience.field !== 'leader' || !b.preLeader || b.preLeader.closed, 'Both combatants must finish their shared preparation before leaders are committed.', ); const field = b.prescience.field; requireRule( field !== 'dial' || !b.noFieldPlayers?.includes(id), 'Atreides may not inspect the number dialed in a No-Field battle.', ); const value = field === 'dial' ? g.advanced || p.faction === 'ixians' ? typeof action.value === 'number' ? action.value : NaN : integer(action.value, 0, (combatArmy(g, p.id, b.territory).normal + combatArmy(g, p.id, b.territory).elite), 'Forces dialed') : action.value === null || action.value === '' ? null : stringField(action.value); requireRule( feasiblePrescience(g, p, field, value), 'Choose an element that permits a legal battle plan and obeys the Voice.', ); if (b.nexusInspection?.mode === 'cunning') b.nexusInspection = nexusRule(() => answerNexusNative(battleInspectionContext(g), b.nexusInspection!, value)); b.prescience.value = value; finishInspectionAnswers(g); return g; } if (t === 'voice') { const b = g.battle; requireRule( b?.preparation?.kind === 'voice' && b.preparation.owner === id, 'Voice must be chosen by Bene Gesserit before prescience and battle plans.', ); requireRule( VOICE_KINDS.includes(String(action.kind)), 'Choose a card type.', ); if (action.kind === 'mirrorWeapon') { const blocked = mirrorWeaponModeBlock(g); requireRule(!blocked, blocked ?? 'Mirror Weapon is unavailable in this battle configuration.'); } requireRule(typeof action.must === 'boolean', 'Choose require or forbid.'); if ((action.kind === 'stoneBurner' || action.kind === 'mirrorWeapon') && action.must) { const target = getPlayer( g, b.attacker === b.preparation.beneficiary ? b.defender : b.attacker, ); const blocked = stoneCompulsionBlock(g, target); requireRule( !blocked, blocked ?? `${action.kind === 'mirrorWeapon' ? 'Mirror Weapon' : 'Stone Burner'} cannot be compelled in this battle.`, ); } b.voice = { target: b.attacker === b.preparation.beneficiary ? b.defender : b.attacker, kind: action.kind as NonNullable['kind'], must: action.must, }; battlePreparation(g, 'prescience'); g.response = { kind: 'voice', owner: id, passed: [] }; return g; } if (t === 'battlePlan') { const b = g.battle; requireRule( !b?.preLeader || b.preLeader.closed, 'Both combatants must finish their shared preparation before leaders are committed.', ); requireRule( b && !b.preparation && !b.revealed && [b.attacker, b.defender].includes(id) && !b.plans[id], 'Your battle plan is not available.', ); requireRule( !b.fullPlan || b.plans[b.fullPlan.target] || id === b.fullPlan.target, 'Wait for the plan requested by special prescience.', ); b.plans[id] = validatePlan(g, p, action); if (b.fullPlan?.target === id) { recordFullPlanInspection(g, b.fullPlan.owner); return g; } if (b.plans[b.attacker] && b.plans[b.defender]) { for (const playerId of b.noFieldPlayers ?? []) { const owner = getPlayer(g, playerId); if (owner.noField?.deployed?.location.territory === b.territory) revealPlayerNoField(g, owner, 'battle'); } b.revealed = true; for (const plan of Object.values(b.plans)) { const l = g.players .flatMap((p) => p.leaders) .find((l) => l.id === plan.leader); if (l?.capturedBy) delete l.concealed; } log(g, 'Both battle plans are revealed.'); if (b.smugglerCollectionVersion === 1) { b.smugglerCollection = pendingSmugglerBattle(g); if (b.smugglerCollection) log(g, `${getPlayer(g, b.smugglerCollection.player).name}'s Smuggler may collect ${b.smugglerCollection.amount} spice from the reveal-time pile if the leader survives. That spice is not yet spendable.`); } nextRevealedDecision(g); } return g; } if (t === 'traitorCall') { const b = g.battle; requireRule( b?.revealed && traitorVoters(g, b).includes(id) && b.traitorCalls[id] === undefined, 'Traitor decision is not available.', ); requireRule(typeof action.call === 'boolean', 'Choose reveal or decline.'); if (g.nexusCards?.cards) ensureTraitorDeclarations(g); if (action.call) { const other = b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker; requireRule( !b.plans[other].kwisatz, 'Kwisatz Haderach prevents this leader from turning traitor.', ); requireRule( matchingTraitor( p.traitors, b.plans[other].leader, cardOf(getPlayer(g, other), b.plans[other].leader), ), 'You do not hold that leader as a traitor.', ); } if (action.call && b.traitorDeclarationVersion) b.traitorDeclarations![id] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), id)); b.traitorCalls[id] = action.call; if (action.call && g.moritaniAssassinate?.owner === id) { requireRule(b.event && !g.moritaniAssassinateCallEvents!.includes(b.event), 'This normal traitor call was already recorded.'); g.moritaniAssassinate.normalTraitorCall = true; g.moritaniAssassinateCallEvents!.push(b.event); } if ( action.call && p.faction === 'harkonnen' && ![b.attacker, b.defender].includes(id) ) { g.response = { kind: 'harkonnenTraitor', owner: id, passed: [] }; return g; } if ( traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined) ) if (!b.territory.startsWith('homeworld:')) advanceBattleOutcome(g); else advanceHomeworldReveal(g); return g; } if (t === 'card') { const c = p.hand.find((c) => c.id === action.card); requireRule( c && (c.kind === 'special' || canUseAsKaramaRole(g, p, c)), 'Select a special card in your hand.', ); if ( canUseAsKaramaRole(g, p, c) && action.mode === 'shipment' ) { const recipient = getPlayer(g, stringField(action.target ?? id)); requireRule( g.phase === 5 && g.active === recipient.id && shipmentAvailable(g,recipient), 'Choose the active player before their shipment.', ); requireRule( !g.karamaShipping, 'A Karama shipment benefit is already available.', ); spendKarama(g, p, c, { kind: 'shipment', recipient: recipient.id }); return g; } if ( canUseAsKaramaRole(g, p, c) && action.mode === 'purchase' ) { requireRule( g.phase === 3 && g.auction && p.hand.length < handLimit(p), 'You may take the current auction card only when eligible to bid.', ); spendKarama(g, p, c, { kind: 'purchase' }); return g; } if (c.effect === 'thumper') { requireRule( g.phase === 1 && !g.spiceSequence && !g.spiceResolution && !g.spiceWindow && !g.nexus, 'Use Thumper at the beginning of Spice Blow, before the first draw.', ); const used = discard(g, p, c.id); log( g, `${p.name} played Thumper in place of the next spice-card reveal.`, ); if (g.semutaPreview) { const continuation: ThumperDiscardContinuation = { kind: 'thumperDiscard', player: p.id, card: used.id, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', }; const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; if (semutaOfferSupported(g, continuation, entries)) { continuation.stateSignature = thumperDiscardSignature(g, continuation); stageTreacheryDiscard(g, 'thumper', entries, continuation); return g; } } blowSpice(g, true); return g; } const availability = ordinaryCardAvailability( { status: g.status, phase: g.phase, active: g.active, me: id, nexusGuildCunning: projectedNexusGuildCunning(g,id), truthtrance: g.truthtrance, phaseOpening: g.phaseOpening, response: g.response, decision: g.decision, shieldWallDestroyed: g.shieldWallDestroyed, spiceWindow: g.spiceWindow, players: [ { id: p.id, faction: p.faction, forces: p.forces, advisors: p.advisors, hand: p.hand, movesAllowed: movesAllowed(g, p), }, ], }, c.id, { amount: action.amount }, ); if (availability && !availability.available) throw new RuleError(availability.reason); if (c.effect === 'hajr') { requireRule( g.ornithopter?.player !== id, 'Hajr combined with Ornithopter awaits its timing ruling.', ); g.hajr.push(id); } else if (c.effect === 'ghola') { applyGholaEffect(g, p, action, c.id); } else if (c.effect === 'harvester') { const blow = g.spiceWindow!; if (blow.sector !== g.storm) { const key = location(blow.territory, blow.sector); g.spice[key] = (g.spice[key] ?? 0) + blow.amount; } blow.amount *= 2; blow.harvested = true; blow.harvesters = (blow.harvesters ?? 0) + 1; g.ready = []; } else if (c.effect === 'weather') { // The shared availability check validated this exact numeric selection. g.stormPending = action.amount as number; g.stormMovementSource = nexusRule(() => createStormSource(g.turn,'weather',g.stormPending!)); g.ready = []; } else if (c.effect === 'atomics') { g.shieldWallDestroyed = true; for (const other of g.players) killTerritory(g, other, 'shield_wall'); g.ready = []; } else throw new RuleError( 'This card’s timing and effect are still being implemented.', ); const used = discard(g, p, c.id); log( g, `${p.name} played ${c.name}.`, c.effect === 'ghola' ? { faction: p.faction, name: 'Ghola revival' } : undefined, ); // Shipment feasibility executes deterministic preparations on private clones. // Its internal mode never comes from the submitted action or player state. if (execution === 'live') stageOrdinaryCardDiscard(g, p, used); return g; } if (t === 'ready') { requireRule( [1, 2, 4, 7, 8].includes(g.phase) || (g.phase === 0 && g.stormPending !== null), 'Complete the current phase action first.', ); requireRule(!g.ready.includes(id), 'You are already ready.'); g.ready.push(id); if (g.ready.length === g.players.length) { if (g.phase === 0) startStormMovement(g, g.stormPending!); else if (g.phase === 1 && g.spiceWindow) finishSpiceWindow(g); else if (g.phase === 1 && (!g.nexus || g.summonedBeforeBlow)) blowSpice(g); else if (g.phase === 1) nextWormRide(g); else nextPhase(g); } return g; } throw new RuleError('That action is not available.'); } export function viewGame(state: Game, id: string) { advancedPreviewIntegrity(state); harkonnenExchangeIntegrity(state); validateEcazLoyalty(state); ecazTreacheryIntegrity(state); ixRicheseTechnologyIntegrity(state); leaderSkillsIntegrity(state); moritaniExtortionIntegrity(state); guildRateIntegrity(state); arrivalOverlapIntegrity(state); discoveryFlightIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); nexusChoamInspectionIntegrity(state); nexusMoritaniRetentionIntegrity(state); nexusChoamBetrayalIntegrity(state); nexusEcazInquiryIntegrity(state); nexusEcazDukeIntegrity(state); richesePairIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); homeworldSubstitutionIntegrity(state); homeworldDefenseIntegrity(state); homeworldShipmentIntegrity(state); karamaConversionIntegrity(state); treacheryDiscardIntegrity(state); shipmentPromiseIntegrity(state); saphoMovementIntegrity(state); ambassadorRelocationIntegrity(state); ecazCollectionIntegrity(state); ecazAllianceIntegrity(state); battleSpecialWeaponIntegrity(state); const g = structuredClone(state); normalizeCardNames(g); settleAdvisors(g); normalizeBattle(g); const me = getPlayer(g, id); let victoryProgress: StrongholdProgress[] = []; let fremenVictory: FremenVictoryProgress | null = null; if (g.status === 'playing' || g.status === 'finished') { try { victoryProgress = strongholdProgress(state).progress; fremenVictory = fremenSpecialVictory(state); } catch (error) { // A malformed restored board has no reliable preview. Authoritative // victory still rejects it through its strict quote. if (!(error instanceof VictoryProgressError)) throw error; } } // A concealed capture may change the real death count without revealing it // to the native owner. Derive guidance from the same projected discs that // the seat can inspect; action validation still uses authoritative custody. const visibleRevivalGame = me.faction === 'ecaz' && g.dukeVidal ? { ...g, dukeVidal: { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) } } : g; const leaderRevivals = leaderRevivalOptions(visibleRevivalGame, { ...me, leaders: me.leaders.map((leader) => projectLeader(g, leader, id)) }); const b = g.battle; const completion = !g.pendingTreacheryDiscard && b && !b.revealed && !b.plans[id] && [b.attacker, b.defender].includes(id) && (b.truthPromises?.some( (promise) => promise.player === id && !promise.released, ) || committedPlanElements(b, id).length > 0) ? findReachableBattlePlan(g, me) : null; return { harkonnenExchangeInspection: projectedHarkonnenInspection(g, id), ecazPoisonIncome: (g.ecazPoisonIncome ?? []).filter((income) => income.player === id) .map(({turn, phase, amount, count}) => ({turn, phase, amount, count})), biddingEnd: g.biddingEnd ? { event: g.biddingEnd.event, owners: [...g.biddingEnd.owners], ready: [...g.biddingEnd.ready], canAct: biddingEndQuiet(g) && g.biddingEnd.owners.includes(id), kaitain: { owner: byFaction(g, 'emperor')!.id, eligible: homeworldRule(() => highKaitainDiscardsAvailable(g, byFaction(g, 'emperor')!.id)) } } : null, combatLocations: combatLocations(g), battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [], battleOrder: projectedBattleOrder(g), guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id), truthShipmentAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'shipment' ? shipmentTruthAnswers(g, me, g.truthtrance.question) : null, shipmentPromises: g.shipmentPromises ?? [], shipmentCompletion: !g.pendingTreacheryDiscard && !g.response && !g.pendingKarama && liveShipmentPromises(g.shipmentPromises ?? [], id, g.turn).some( (p) => p.answer, ) ? findShipmentCompletion(g, me) : null, truthBattleAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'battlePlan' ? battleTruthAnswers(g, me, g.truthtrance.question.claim) : null, truthtrance: g.truthtrance ?? null, truthHistory: g.truthHistory ?? [], truthAnswer: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'fact' ? truthFactAnswer(me, g.truthtrance.question.fact, truthKnowledgeOf(g, me)) : null, schema: g.schema, botsPending: g.botsPending ?? false, semutaReaction: projectedSemutaReaction(g, me), automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || grummanCollectionAutomatic(g), botNextActionAt: g.botNextActionAt ?? null, code: g.code, version: g.version, host: g.host, status: g.status, setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null, setupPending: setupPending(g), leaderSkills: projectedLeaderSkills(g, id), bureaucrat: projectedBureaucrat(g, id), bribeOptions: projectedBribes(g, me), mentat: projectedMentat(g, id), rihani: projectedRihani(g, id), advanced: g.advanced, advancedPreview: g.advancedPreview === true, dukeVidal: g.dukeVidal ? { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) } : null, moritaniTerror: g.moritaniTerror ? projectTerror(g.moritaniTerror, me.faction === 'moritani') : null, moritaniAtomics: g.moritaniAtomics ? { territory: g.moritaniAtomics.territory, turn: g.moritaniAtomics.turn, moritaniId: g.moritaniAtomics.moritaniId, allyAtActivation: g.moritaniAtomics.allyAtActivation } : null, arrivalOverlapMode: arrivalOverlapModeSupported(g), arrivalOverlap: g.pendingArrivalOverlap ? { first: g.pendingArrivalOverlap.first, active: g.pendingArrivalOverlap.stage === 'first' ? g.pendingArrivalOverlap.first : g.pendingArrivalOverlap.first === 'ambassador' ? 'terror' : 'ambassador', entrant: g.pendingArrivalOverlap.entrant, territory: g.pendingArrivalOverlap.territory, } : null, extortion: { deferred: g.moritaniExtortion?.stage === 'reserved' ? 5 : 0, pending: g.moritaniExtortion?.stage === 'payment' && g.decision?.kind === 'moritaniExtortion' ? { event: g.decision.event, owner: g.moritaniExtortion.owner, player: g.decision.player, amount: 3 as const, canPay: me.id === g.decision.player ? me.spice >= 3 : null, } : null, }, moritaniPendingPlacement: me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null, grummanCollection: projectedGrummanCollection(g, id), tupileIntelligence: projectedTupileIntelligence(g, id), terrorEntry: g.pendingTerrorEntry ? (() => { const entry = g.pendingTerrorEntry!; const token = g.moritaniTerror?.tokens.find( (t) => t.id === entry.token, ); return { entrant: entry.entrant, territory: entry.territory, sector: entry.sector, cause: entry.cause, stage: entry.stage, ...(me.faction === 'moritani' && entry.stage === 'select' ? { candidates: entry.candidates!.map((id) => { const candidate = g.moritaniTerror!.tokens.find((t) => t.id === id)!; return { token: id, kind: candidate.kind, canReveal: !terrorRevealBlocked(g, entry, candidate.kind), canOfferAlliance: !terrorAllianceBlocked(g, entry, candidate.kind), revealBlocked: terrorRevealBlocked(g, entry, candidate.kind) ?? undefined }; }) } : {}), ...(me.faction === 'moritani' && token && entry.stage !== 'select' ? { kind: token.kind as TerrorKind, canOfferAlliance: !terrorAllianceBlocked( g, entry, token.kind, ), allianceBlockedReason: terrorAllianceBlocked(g, entry, token.kind) ?? undefined, canReveal: !terrorRevealBlocked(g, entry, token.kind), revealBlocked: terrorRevealBlocked(g, entry, token.kind) ?? undefined, ...(token.kind === 'sneakAttack' ? { sneakAttack: sneakAttackOptions(g, entry) } : {}), } : {}), }; })() : null, ixTechnology: me.faction === 'ixians' ? { setup: g.ixSetupCards ?? null, pool: g.ixAuction?.cards.length ? [...g.ixAuction.cards].sort((a, b) => a.id.localeCompare(b.id)) : null, known: g.ixAuctionKnown?.turn === g.turn ? [...g.ixAuctionKnown.cards].sort((a, b) => a.id.localeCompare(b.id), ) : [], used: g.ixTechnologyTurn === g.turn, } : null, ixPurchased: g.pendingIxAlly?.player === id ? (me.hand.find((c) => c.id === g.pendingIxAlly!.card) ?? null) : null, ambassadors: g.ecazAmbassadors ? { tokens: g.ecazAmbassadors.tokens.map((token) => ({ ...token })), nextCost: (g.ecazAmbassadors.placement?.turn === g.turn ? g.ecazAmbassadors.placement.count : 0) + 1, blocked: g.ecazAmbassadors.placement?.turn === g.turn && g.ecazAmbassadors.placement.blocked, completedTurn: g.ecazPlacementTurn ?? null, } : null, ambassadorEntry: g.pendingAmbassador ? (() => { const entry = g.pendingAmbassador!; const owner = getPlayer(g, entry.owner); const entrant = getPlayer(g, entry.entrant); const token = g.ecazAmbassadors!.tokens.find( (t) => t.id === entry.token, )!; return { event: entry.event, owner: entry.owner, entrant: entry.entrant, territory: entry.territory, stage: entry.stage, effect: entry.effect ?? token.effect, beneficiary: entry.beneficiary ?? null, shipment: entry.stage === 'ship' && id === entry.beneficiary ? (() => { const choices = guildAmbassadorRule(() => guildAmbassadorShipments(g, id), ); return { ...choices, destinations: choices.destinations.map((destination) => ({ ...destination, ...ambassadorArrivalChoices( g, id, 'reserves', destination.territory, ), })), }; })() : null, movement: entry.stage === 'move' && id === entry.beneficiary ? ambassadorRelocationMovement(g, id) : null, allianceOffer: entry.stage === 'offer' && token.effect === 'ecaz' && id === owner.id ? { blocked: homeworldAllianceReason(g, owner.id, entrant.id) ?? ecazAllianceBlock(g, owner.id, entrant.id) } : null, dukeAcquisition: entry.stage === 'offer' && token.effect === 'ecaz' && id === owner.id ? { blocked: ambassadorEffectBlock(g, 'ecaz', owner, entrant) } : null, beneficiaries: entry.stage === 'offer' && id === owner.id ? g.players .filter( (p) => p.id === owner.id || (p.id === owner.ally && p.ally === owner.id), ) .map((p) => ({ player: p.id, blocked: ambassadorEffectBlock(g, token.effect, p, entrant) ?? (token.effect === 'beneGesserit' && !entry.copyChoices.some( (effect) => !ambassadorEffectBlock(g, effect, p, entrant), ) ? 'No copied effect is currently available.' : null), })) : [], copies: entry.stage === 'copy' && id === entry.beneficiary ? entry.copyChoices.map((effect) => ({ effect, blocked: ambassadorEffectBlock(g, effect, me, entrant), })) : [], cards: entry.stage === 'cards' && id === entry.beneficiary ? me.hand.map((card) => ({ card: card.id, blocked: ambassadorDiscardBlock(g, me, card, entry.effect), })) : [], }; })() : null, ambassadorInsights: (g.ambassadorInsights ?? []).filter( (entry) => entry.viewer === id, ), auditor: g.pendingAuditor ? { event: g.pendingAuditor.event, owner: g.pendingAuditor.owner, opponent: g.pendingAuditor.opponent, survived: g.pendingAuditor.survived, count: currentAuditCount(g), } : null, auditorInsight: g.auditorInsight?.viewer === id ? structuredClone(g.auditorInsight) : null, nexusChoamInsight: g.nexusChoamInsight?.viewer === id ? structuredClone(g.nexusChoamInsight) : null, mobileStronghold: g.mobileStronghold ?? null, mobileRoute: g.pendingMobileMove ? { route: g.pendingMobileMove.route, collect: g.pendingMobileMove.collect, } : null, techTokens: g.techTokens ?? null, ecazLoyalty: g.ecazLoyalty?.card ? { player: g.ecazLoyalty.player, card: g.ecazLoyalty.card } : null, strongholdCards: g.strongholdCards ?? null, discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)), ecologicalStorm: g.decision?.kind === 'ecologicalStorm' && g.decision.player === id && g.ecologicalStorm?.stage === 'choose' ? {...nexusRule(() => discoveryStormOffer(g))!,event:g.ecologicalStorm.event} : null, discoveryEntry: g.discoveryEntry?.stage === 'choose' && g.decision?.kind === 'discoveryEntry' && g.decision.player === id ? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null, greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null, nexusCards: projectedNexusCards(g, id), nexusChoamTrade: currentNexusChoamTrade(g, id), nexusChoamInspection: quoteNexusChoamInspection(g, id), nexusChoamBetrayal: quoteNexusChoamBetrayal(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusEcazInquiry: { offer: quoteEcazInquiry(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), history: ecazInquiryHistory(g, id), }, nexusEcazDuke: nexusRule(() => quoteNexusEcazDuke(g, id)), nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id), nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id), nexusBgBetrayal: nexusBgBetrayalOffer(g, id), nexusFremenRevival: fremenNexusRevivalOffer(g,id), nexusMoritani: nexusMoritaniOffer(g,id), nexusMoritaniBetrayal: moritaniBetrayalOffer(g,id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusEcazBetrayal: ecazBetrayalOffer(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusFremenBetrayal: fremenBetrayalOffer(g, id, g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusRichese: nexusRicheseOffer(g,id), nexusRicheseCunning: nexusRicheseCunningOffer(g, id), nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id), nexusGuildCunning: projectedNexusGuildCunning(g,id), nexusTraitors: projectedNexusTraitors(g, id), nexusTleilaxu: projectedNexusTleilaxu(g, id), nexusSuboids: projectedNexusSuboids(g, id), nexusAdvisors: projectedNexusAdvisors(g, id), nexusSardaukar: projectedNexusSardaukar(g,id), nexusAtreides: nexusAtreidesOffer(g, id), homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id), caladanReinforcement: projectedHomeworldVictoryReturn(g, id), homeworldRevivalBlocks: homeworldRevivalChoiceBlocks(g, me), homeworlds: g.homeworlds ? { worlds: homeworldRule(() => homeworldTable(g)) } : null, homeworldMove: g.homeworlds?.custody && g.advanced && me.faction === 'emperor' ? { event: emperorHomeworldMoveEvent(g, me), blocked: emperorHomeworldMoveBlock(g, me), remaining: Math.max(0, movesAllowed(g, me) - me.moved), } : null, homeworldShipment: g.homeworlds?.custody ? {event: homeworldShipmentEvent(g, me), blocked: homeworldShipmentBlock(g, me)} : null, guildHomeworldShipment: g.homeworlds?.custody && me.faction === 'guild' ? {event: homeworldShipmentEvent(g, me, 'arrakis'), blocked: guildHomeworldShipmentBlock(g, me)} : null, junctionTransport: junctionTransportWindow(g), homeworldMobility: { foresightBlocked: homeworldRule(() => homeworldMovementForesightBlock(g, me.id)), mobileStrongholdBlocked: homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, me.id)), noFieldMovementBlocked: homeworldRule(() => homeworldNoFieldMovementBlock(g, me.id)), advisorSinkMaximum: spiritualAdvisorMaximum(g, me.id), advisorAccompanyMaximum: spiritualAdvisorMaximum(g, me.id, 'arrakeen'), }, expansions: g.expansions, ecazTreachery: !!g.ecazTreachery, turn: g.turn, phase: g.phase, phaseOpening: g.phaseOpening ? { passed: g.phaseOpening.passed } : null, storm: g.status === 'lobby' ? STORM_START_SECTOR : g.storm, playerPositions: normalizedPlayerPositions(g), order: g.order, active: g.active, movementRemaining: g.movementRemaining ?? null, guildTimingLocked: g.guildTimingLocked ?? false, ready: g.ready, spice: g.spice, // Public physical draw piles only; auction pools and private holdings stay separate. drawPiles: g.status === 'lobby' ? null : { treachery: g.deck.length, spice: g.spiceDeck.length }, log: g.log, nexus: g.nexus, summonedWorm: g.summonedWorm ? { territory: g.summonedWorm.territory } : null, sandtrout: g.sandtrout ?? false, spiceWindow: g.spiceWindow ?? null, spicePile: g.spiceSequence?.pile ?? 0, beforeSpiceDraw: g.phase === 1 && g.nexusCards?.phase?.stage !== 'drawing' && !g.spiceSequence && !g.spiceResolution && !g.spiceWindow && !g.nexus, spiceDiscardTop: g.spiceDiscard.map((pile) => pile.at(-1) ?? null), allianceOffers: g.allianceOffers, homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id) .flatMap((p) => { const reason = homeworldAllianceReason(g, id, p.id); return reason ? [[p.id, reason]] : []; })), ecazSpice: g.ecazCollection?.stage === 'allocation' && g.ecazCollection.allocation ? { event: g.ecazCollection.event, allocation: [ g.ecazCollection.allocation.lots[0].ecaz, g.ecazCollection.allocation.lots[0].ally, ].includes(id) ? g.ecazCollection.allocation : null, player: g.ecazCollection.allocation.player, territory: g.ecazCollection.allocation.lots[ g.ecazCollection.allocation.index ].territory, } : null, winner: g.winner, victoryProgress, fremenVictory, shieldWallDestroyed: g.shieldWallDestroyed, stormDialers: g.stormDialers, stormSubmitted: Object.keys(g.stormDials), karamaShipping: g.karamaShipping ? { player: g.karamaShipping.player, owner: g.karamaShipping.owner } : null, guildRateCanceled: g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id, stormPending: g.stormPending ?? null, stormForecast: me.faction === 'fremen' && g.stormCardKnown ? g.stormCard : null, stormRevealed: g.phase === 0 && g.stormDialers.length > 0 && g.stormDialers.every((id) => g.stormDials[id] !== undefined) ? g.stormDials : null, me: id, choamWorthless: me.faction === 'choam' ? { pending: g.pendingChoamWorthless?.owner === id ? g.pendingChoamWorthless.card : null, baliset: gameTerritories(g) .filter((t) => at(me, t.id) > 0) .map((t) => t.id), gamont: g.players .filter((p) => p.id !== id) .flatMap((p) => Object.entries(presenceByLocation(p)).flatMap( ([key, count]) => { const elites = p.elites?.forces[key] ?? 0; const marker = p.noField?.deployed; const noField = !!marker && location( marker.location.territory, marker.location.sector, ) === key; return [ ...(count > elites ? [{ target: p.id, key, elite: 0, noField }] : []), ...(elites > 0 ? [{ target: p.id, key, elite: 1, noField: false }] : []), ]; }, ), ), targets: g.players .filter((p) => !g.revivalRules?.freeBlocked?.includes(p.id)) .map((p) => p.id), cards: me.hand.filter( (c) => c.kind === 'worthless' && [ 'Baliset', 'Kulon', 'La La La', 'Trip to Gamont', 'Jubba Cloak', ].includes(c.name) && !choamWorthlessBlocked(g, c.id), ), plays: choamPowerPlays(g,me), } : null, balisetRestrictions: g.phase === 5 ? (g.choamBaliset ?? []).filter((b) => b.turn === g.turn) : [], mentatVictoryPending: g.phase === 8 && g.status === 'playing' && (!!byFaction(g, 'choam') || g.decision?.kind === 'moritaniPlacement' || g.decision?.kind === 'moritaniExtortion' || g.response?.kind === 'moritaniPlacement'), choamMovementBonus: g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0, choamCashIn: g.status === 'playing' && me.faction === 'choam' && g.advanced && !me.specialKaramaUsed ? { cards: cashInCards(g, me), karamas: cashInCards(g, me).filter((c) => canUseAsKaramaRole(g, me, c), ), } : null, choamMarket: g.choamMarket ? { owner: g.choamMarket.owner, ...(g.choamMarket.owner === id ? { sales: saleOptions(me.hand, g.choamMarket.blocked).filter((sale) => !homeworldRule(() => homeworldWorthlessSaleBlock( g, me.id, me.hand.find((card) => card.id === sale.card)!, )), ), worthlessSaleBlocked: homeworldRule(() => homeworldWorthlessSaleBlock(g, me.id, { kind: 'worthless' }), ), canTrade: !!me.ally && g.choamTradeTurn !== g.turn, tradeAttempted: !!g.choamMarket.tradeAttempted, } : {}), ...(g.choamMarket.trade && [g.choamMarket.owner, g.choamMarket.trade.ally].includes(id) ? { offered: getPlayer(g, g.choamMarket.owner).hand.find( (c) => c.id === g.choamMarket!.trade!.offered, ), returned: getPlayer(g, g.choamMarket.trade.ally).hand.find( (c) => c.id === g.choamMarket!.trade!.returned, ), } : {}), } : null, inflation: g.inflation ?? null, inflationUsed: g.inflationUsed ?? false, inflationAttempted: g.inflationAttemptTurn === g.turn, charity: { ...charityQuote(g, me), multiplier: charityMultiplier(g), amount: charityAmount(g, me), payer: charityPayer(g)?.id ?? null, incomePending: !!byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn, incomeCanceled: g.choamCharity?.turn === g.turn && g.choamCharity.canceled, }, moritaniAssassinate: projectedMoritaniAssassinate(g,id), moritaniRetention: g.moritaniRetention ?? null, nexusMoritaniRetention: quoteNexusMoritaniRetention(g, id), moritaniRetentionCard: g.moritaniRetention?.keep ? (cardOf( getPlayer(g, g.moritaniRetention.player), g.moritaniRetention.keep, ) ?? null) : null, decision: g.decision?.kind === 'capturedLeader' && !g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract).leader) && ![g.decision.player, g.decision.controller ?? g.decision.owner].includes( id, ) ? { ...g.decision, leader: '', owner: '' } : g.decision?.kind === 'faceDance' ? { ...g.decision, ...(faceDanceReturnBlock(g, g.decision.winner) ? {blocked: faceDanceReturnBlock(g, g.decision.winner)!} : {}) } : g.decision?.kind === 'guildShipment' && g.decision.noFieldSkillProof !== undefined ? { ...g.decision, noFieldSkillProof: undefined } : (g.decision ?? null), response: g.response ? { ...g.response, ...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}), ...(g.response.guildContributions ? { guildContributions: undefined } : {}), ...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}), ...(g.response.sandmasterProof ? { sandmasterProof: undefined } : {}), ...(g.response.noFieldSkillProof ? { noFieldSkillProof: undefined } : {}), passed: g.response.passed.includes(id) ? [id] : [], ...(g.response.kind === 'revivalIncome' && ![g.response.owner, g.response.recipient].includes(id) ? { amount: undefined } : {}), ...(g.response.kind === 'faceDancerReplacement' && g.response.owner !== id ? { intent: undefined } : {}), ...(g.response.kind === 'emperorGift' && ![g.response.owner, g.response.recipient].includes(id) ? { amount: undefined } : {}), } : null, paymentIncome: currentFactionPayment(g), responseControls: g.response ? { cancelCards: responseCancelCards(g, me, g.response), hasPassed: g.response.passed.includes(id), } : undefined, revivalPrevention: g.revivalPrevention?.turn === g.turn ? g.revivalPrevention : null, emperorExtra: g.emperorExtra ?? {}, freeRevival: g.freeRevival ?? [], recruitsPreview: recruitsPreview(g, me), revivalRules: { ...(g.revivalRules ?? newRevivalRules()), earlyBlocked: (g.revivalRules?.earlyBlocked ?? []).filter( (key) => me.faction === 'tleilaxu' || key.startsWith(`${id}:`), ), }, revivalRequests: Object.fromEntries( Object.entries(g.revivalRequests ?? {}).filter( ([player]) => player === id || me.faction === 'tleilaxu', ), ), revival: { pending: !!g.pendingRevival, specialKaramaBlock: g.decision?.kind === 'revivalStop' ? homeworldRevivalKaramaBlock(g, g.decision.recipient) : null, leaders: leaderRevivals.leaders, kwisatz: leaderRevivals.kwisatz, cycleBlock: leaderRevivals.cycleBlock, dukeBlocked: leaderRevivals.dukeBlocked, prevented: revivalPrevented(g, me.id), eliteRemaining: eliteRevivalRemaining(me, g.advanced), limit: normalForceRevivalLimit(g, me), forcesRemaining: forceRevivalRemaining(g, me), discount: revivalDiscount(g, me), choamBlocked: !!g.revivalRules?.choamBlocked, freeRemaining: freeRevivalRemaining(g, me), homeworldBonus: homeworldLowBonus(g, me.id), tleilaxuHomeworldIncomeBlocked: homeworldRule(() => tleilaxuHomeworldFreeIncomeBlocked(g), ), freeBlocked: g.revivalRules?.freeBlocked?.includes(id) ?? false, }, ghola: gholaOptions(g, me), aid: { pledged: g.aid?.[id]?.amount ?? 0, available: g.phase === 6 ? (battleAidFor(g, me)?.amount ?? 0) : me.ally && g.aid?.[me.ally]?.recipient === id ? g.aid[me.ally].amount : 0, }, players: g.players.map((p) => ({ ...(p.noField ? { noField: projectRicheseNoField(p.noField, false) } : {}), id: p.id, name: p.name, bot: p.bot, autopilot: p.autopilot, faction: p.faction, handLimit: handLimit(p), ready: p.ready, reserves: p.reserves, tanks: p.tanks, forces: p.forces, elites: p.elites, advisors: p.advisors, advisorSetup: p.advisorSetup, specialKaramaUsed: p.specialKaramaUsed ?? false, fremenMovementBlocked: p.fremenNexusMovementBlockedTurn === g.turn || (p.fremenMovementBlocked?.turn === g.turn && p.fremenMovementBlocked.move === p.moved), fremenNexusMovementBlocked: p.fremenNexusMovementBlockedTurn === g.turn, fremenNexusWormBlocked: p.fremenNexusWormBlockedTurn === g.turn, ixMovementBlocked: p.ixMovementBlocked?.turn === g.turn && p.ixMovementBlocked.move === p.moved, revealedTraitors: p.revealedTraitors ?? [], revealedFaceDancers: (p.faceDancers ?? []) .filter((c) => c.revealed) .map((c) => c.leader), leaders: [ ...(g.dukeVidal?.controller === p.id && !g.dukeVidal.leader.capturedBy && !g.dukeVidal.leader.gholaBy && !(g.advanced && byFaction(g, 'harkonnen')) ? [{ ...g.dukeVidal.leader, controller: p.id }] : []), ...p.leaders.map((l) => projectLeader(g, l, id)), ...g.players .filter((owner) => owner.id !== p.id) .flatMap((owner) => owner.leaders .filter( (l) => l.gholaBy === p.id || (p.id === id && l.capturedBy === p.id), ) .map((l) => projectLeader(g, l, id)), ), ], ally: p.ally, allySinceTurn: p.allySinceTurn, ...(p.id === id ? { spice: p.spice, charityClaimed: p.charityTurn === g.turn, ...(g.advanced && p.faction === 'atreides' ? { kwisatz: { active: p.battleLosses >= 7, dead: p.kwisatz?.dead ?? false, usedAt: p.kwisatz?.usedAt, losses: p.battleLosses, }, } : {}), hand: p.hand, traitors: p.traitors, faceDancers: p.faceDancers, faceDancerReplaced: p.faceDancerReplacedTurn === g.turn, traitorChoices: p.traitorChoices, prediction: p.prediction, bribes: p.bribes, shipped: p.shipped, moved: p.moved, movesAllowed: movesAllowed(g, p), revived: p.revived, leaderRevived: p.leaderRevived, gholaBlocked: Object.entries(p.gholaBlocked ?? {}) .filter(([, turn]) => turn === g.turn) .map(([leader]) => leader), } : {}), ...(g.phase === 3 || p.id === id ? { handCount: p.hand.length } : {}), })), richeseNoField: (() => { const owner = byFaction(g, 'richese'); if (!owner?.noField || !owner.noFieldEvent) return null; const mine = owner.id === me.id; const shipBlock = mine ? noFieldShipBlock(g, owner) : 'Only Richese controls this token inventory.'; const revealBlock = mine ? noFieldRevealBlock(g, owner) : 'Only Richese controls this concealed token.'; return { owner: owner.id, event: owner.noFieldEvent, public: projectRicheseNoField(owner.noField, false), private: mine ? projectRicheseNoField(owner.noField, true).private : null, canShip: !shipBlock, shipBlock, canReveal: !revealBlock, revealBlock, canOfferAlly: mine && !noFieldAllyOfferBlock(g, owner), allyOfferBlock: mine ? noFieldAllyOfferBlock(g, owner) : 'Only Richese may offer these tokens.', allyDeclined: g.richeseAllyDeclined?.turn === g.turn && g.richeseAllyDeclined.recipient === owner.ally, allyOffer: (() => { const offer = g.richeseAllyOffer; if (!offer || ![offer.owner, offer.recipient].includes(id)) return null; const { event, owner, recipient, territory, sector, payer } = offer; try { const { cost, ownerPayment, recipientPayment, amount, value, eliteMin, eliteMax, } = alliedNoFieldQuote(g, offer, offer.recipient === id); return { event, owner, recipient, territory, sector, payer, cost, ownerPayment, recipientPayment, amount, value, eliteMin, eliteMax, blocked: null as string | null, }; } catch (error) { if (!(error instanceof RuleError)) throw error; return { event, owner, recipient, territory, sector, payer, cost: 0, ownerPayment: 0, recipientPayment: 0, amount: 0, value: 0 as const, eliteMin: 0, eliteMax: 0, blocked: error.message, }; } })(), }; })(), richeseGift: richeseGiftView(g, me), distrans: distransView(g, me), nullentropy: nullentropyView(g, me), ornithopter: ornithopterView(g, me), discoveryOrnithopter: discoveryFlightOffer(g, id, { movesAllowed: movesAllowed(g, me), blocked: g.response || g.decision || g.pendingKarama || g.phaseOpening || g.pendingTreacheryDiscard || g.pendingNullentropy ? 'Finish the current response or decision before declaring movement.' : null, }), residualPoison: residualPoisonView(g, me), portableSnooper: portableSnooperView(g, me), saphoOptions: saphoOptions(g, me), saphoMovementLast: g.saphoMovementLast ? { event: g.saphoMovementLast.event, turn: g.saphoMovementLast.turn, player: g.saphoMovementLast.player, } : null, playedOrnithopter: g.ornithopter ? { player: g.ornithopter.player, card: g.ornithopter.card, mode: g.ornithopter.mode, completed: g.ornithopter.completed, } : null, richeseSpecialKarama: richeseSpecialKaramaView(g, me), ixRicheseTechnology: g.pendingIxRicheseTechnology ? { event: g.pendingIxRicheseTechnology.event, player: g.pendingIxRicheseTechnology.player, owner: g.pendingIxRicheseTechnology.owner, source: g.pendingIxRicheseTechnology.source, exchangeBlocked: 'Ixian exchange custody for Richese lots is not implemented. You can explicitly continue without this exchange; Technology remains available for later lots.', } : null, richeseBidding: g.richeseBidding?.turn === g.turn ? { owner: g.richeseBidding.owner, event: g.richeseBidding.event, stage: g.richeseBidding.stage, position: g.richeseBidding.position, normalCount: g.richeseBidding.normalCount, offerBlocked: null, cache: g.richeseBidding.owner === id ? (g.richeseCache ?? []) : null, } : null, richeseAuction: g.richeseAuction ? { ...projectRicheseAuction(g.richeseAuction, id), card: g.richeseAuction.source === 'cache' || g.richeseAuction.owner === id || (me.faction === 'atreides' && g.richesePeekKnown) ? (g.richeseOfferedCard ?? richeseLotCard(g) ?? null) : null, claim: g.richeseClaim ?? null, ownAllyPayment: g.richeseFunding?.[id]?.allyPayment ?? 0, ownAvailable: uncommittedSpice(g, me) + richeseOwnCommitment(g.richeseAuction, g.richeseFunding ?? {}, id), allyAvailable: aidFor(g, me)?.amount ?? 0, peekKnown: !!g.richesePeekKnown, } : null, auction: g.auction ? { bid: g.auction.bid, bidder: g.auction.bidder, active: g.auction.active, ...(g.auction.bidder === id ? { allyPayment: g.auction.allyPayment ?? 0 } : {}), remaining: g.auction.cards.length - g.auction.index, card: me.faction === 'atreides' && g.auction.peekKnown ? g.auction.cards[g.auction.index] : null, } : null, battle: b ? { locationName: combatLocationName(g, b.territory), native: homeworldBattleLocation(g, b.territory)?.native ?? null, nativeBattleStrength: homeworldBattleLocation(g, b.territory)?.nativeBattleStrength ?? 0, opponentForces: [b.attacker, b.defender].includes(id) && !getPlayer(g, b.attacker === id ? b.defender : b.attacker).noField?.deployed ? combatForces(g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), b.territory, me) : null, strongholdCopy: b.strongholdCopy ?? null, strongholdEffects: Object.fromEntries( [b.attacker, b.defender].map((player) => [ player, strongholdEffect(g, player), ]), ) as Record, tieWinner: battleTieWinner(g), noFieldPlayers: b.noFieldPlayers ?? [], ownForces: [b.attacker, b.defender].includes(id) ? combatForces( g, me, b.territory, getPlayer(g, b.attacker === id ? b.defender : b.attacker), ) : null, truthPromises: b.truthPromises ?? [], compliantPlan: completion && !completion.actions.length && !completion.waitingForIncome ? completion.plan : null, compliantPreparation: completion && (completion.actions.length || completion.waitingForIncome) ? completion : null, poisonTooth: b.revealed ? (b.poisonTooth ?? {}) : {}, stoneBurner: b.revealed ? (b.stoneBurner ?? {}) : {}, stoneBurnerContext: [b.attacker, b.defender].includes(id) ? { blocked: stoneTimingBlock(g), opponentPools: stonePublicPools( g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), me, ), } : null, lateDefense: b.revealed ? (b.lateDefense ?? {}) : {}, smugglerCollectionEnabled: b.smugglerCollectionVersion === 1, smugglerCollection: b.revealed && b.smugglerCollection ? { player: b.smugglerCollection.player, leader: b.smugglerCollection.leader, amount: b.smugglerCollection.amount, before: b.smugglerCollection.before } : null, diplomatDefense: b.revealed && b.diplomatDefense ? { player: b.diplomatDefense.player, event: b.diplomatDefense.event, leader: b.diplomatDefense.leader, cards: [...b.diplomatDefense.cards], source: b.diplomatDefense.source, kind: b.diplomatDefense.kind, stage: b.diplomatDefense.stage, card: b.diplomatDefense.card } : null, harassAllocation: b.revealed && b.harassAllocation && (b.harassAllocation.player === id || b.harassAllocation.stage === 'selected') ? { event: b.harassAllocation.event, context: harassWithdrawContext(g, getPlayer(g, b.harassAllocation.player)), dial: b.plans[b.harassAllocation.player].dial, support: b.plans[b.harassAllocation.player].support, selection: b.harassAllocation.selection ? structuredClone(b.harassAllocation.selection) : null } : null, fullPlan: b.fullPlan ?? null, fullPlanInsight: b.fullPlan?.owner === id && b.plans[b.fullPlan.target] && !b.revealed ? { target: b.fullPlan.target, plan: b.plans[b.fullPlan.target], cards: getPlayer(g, b.fullPlan.target).hand.filter((c) => { const plan = b.plans[b.fullPlan!.target]; return [plan.leader, plan.weapon, plan.defense].includes( c.id, ); }), } : null, event: b.event ?? null, preLeader: b.preLeader ?? null, territory: b.territory, attacker: b.attacker, aggressor: battleAggressor(g), chooser: b.chooser ?? b.attacker, defender: b.defender, revealed: b.revealed, kwisatzBlocked: b.kwisatzBlocked ?? false, eliteBlocked: b.eliteBlocked ?? [], fremenSupportBlocked: b.fremenSupportBlocked ?? false, choamAidBlocked: b.choamAidBlocked ?? false, cards: b.revealed ? [b.attacker, b.defender].flatMap((playerId) => { const plan = b.plans[playerId]; return getPlayer(g, playerId).hand.filter((c) => [ plan.leader, plan.weapon, plan.defense, b.lateDefense?.[playerId], ].includes(c.id), ); }) : [], submitted: Object.keys(b.plans), plans: b.revealed ? b.plans : b.plans[id] ? { [id]: b.plans[id] } : {}, traitorSubmitted: Object.keys(b.traitorCalls), traitorVoters: traitorVoters(g, b), preparation: b.preparation ?? null, nexusInspection: b.nexusInspection ? { event: b.nexusInspection.event, mode: b.nexusInspection.mode, owner: b.nexusInspection.owner, target: b.nexusInspection.target, field: b.nexusInspection.field, stage: b.nexusInspection.stage, } : null, nexusInsights: projectedNexusInsights(g, id), harassWithdraw: harassWithdrawPreview(g, me), reinforcements: reinforcementsPreview(g, me), ownCommitments: committedPlanElements(b, id), prescience: b.prescience ? { player: b.prescience.player, field: b.prescience.field, answered: 'value' in b.prescience, } : undefined, voice: b.voice, insight: b.prescience && 'value' in b.prescience && (b.prescience.player === id || [b.attacker, b.defender].includes(id)) ? (() => { const target = getPlayer(g, b.prescience!.player === b.attacker ? b.defender : b.attacker); const value = inspectedPlanValue(target, b.prescience!.field, b.prescience!.value); return { field: b.prescience!.field, value, label: g.players.flatMap(p => [...p.leaders, ...p.hand]) .concat(g.dukeVidal ? [g.dukeVidal.leader] : []) .find(item => item.id === value)?.name ?? String(value ?? 'None') }; })() : null, } : null, spicePeek: g.phase === 5 && me.faction === 'atreides' && g.spicePeekKnown ? (g.spiceDeck[0] ?? null) : null, allLeaders: [ ...g.players.flatMap((p) => p.leaders), ...(g.dukeVidal ? [g.dukeVidal.leader] : []), ].map((l) => ({ id: l.id, name: l.name, faction: l.faction, strength: l.strength, })), }; } export type GameView = ReturnType & { seatAiDelegations?: import('../lib/seat-ai-delegation').SeatAiDelegationView[]; roomControl?: import('../lib/room-control').RoomControl; };