'use client'; import { RicheseBidPromisePanel } from './richese-bid-promise-panel'; import { EarlyRicheseBidPromises } from './early-richese-bid-promises'; import { TravelPromises } from './travel-promises'; import { TurnActionPromises } from './turn-action-promises'; import { MixedTraitorPromises } from './mixed-traitor-promises'; import { MixedBattlePromises } from './mixed-battle-promises'; import { FutureBattleLeaderPromises } from './truth-future-battle-leader'; import { TraitorDecisionPromises } from './traitor-decision-promises'; import { MovementPromises } from './movement-promises'; import { MovementExpressionPromises } from './movement-expression-promises'; import { MixedMovementPromises } from './mixed-movement-promises'; import { HomeworldEcazDuke } from './homeworld-ecaz-duke'; import { integerInputValue } from '../lib/integer-input'; import { normalAuctionHandBlock } from '@/game/normal-auction'; import { EcazSetup } from './ecaz-setup'; import { StormCardInspector, StormCardLogInspector } from './storm-cards'; import { stormCardDistance } from '@/game/storm-cards'; import { KwisatzInspector } from './kwisatz-inspector'; import { ForceInspector } from './force-inspector'; import { FactionInspector } from './faction-inspector'; import { EcazLoyaltyCard } from './ecaz-loyalty'; import { DrawPiles } from './draw-piles'; import { RemovedTreachery } from './removed-treachery'; import { HandBrowser } from './hand-browser'; import { LobbyBotControls } from './lobby-bot-controls'; import { AdvancedPreviewNotice, RulesetControls } from './ruleset-controls'; import { EcazOccupyLeadChoice, EcazOccupyBattleSummary, EcazOccupyDialExplanation } from './ecaz-occupy'; import { ecazOccupyOwnProfile, ecazOccupyPlanControl } from '@/game/ecaz-occupy-options'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options'; import { nexusCoreProfile } from '@/game/faction-module-profile'; import { matchesShipment, liveShipmentPromises, } from '@/game/shipment-promises'; import { ShipmentPromises } from './shipment-promises'; import { AlliancePromises } from './alliance-promises'; import { AllianceExpressionPromises } from './alliance-expression-promises'; import { MixedAlliancePromises } from './mixed-alliance-promises'; import { BribePromises } from './bribe-promises'; import { BribeExpressionPromises } from './bribe-expression-promises'; import { MixedBribePromises } from './mixed-bribe-promises'; import { MixedRevivalPromises } from './mixed-revival-promises'; import { MixedBidPromises } from './mixed-bid-promises'; import { RevivalPromises } from './revival-promises'; import { RevivalExpressionPromises } from './revival-expression-promises'; import { LeaderRevivalPromises } from './leader-revival-promises'; import { BidPromises } from './bid-promises'; import { PhaseBidPromises } from './phase-bid-promises'; import { HomeworldShipment } from './homeworld-shipment'; import { HomeworldRevivalDeployment } from './homeworld-revival-deployment'; import { CaladanReinforcement } from './caladan-reinforcement'; import { GrummanCollection } from './grumman-collection'; import { HomeworldOccupiedIncome } from './homeworld-occupied-income'; import { HomeworldOccupiedPercentage } from './homeworld-occupied-percentage'; import { HomeworldOccupiedBonus } from './homeworld-occupied-bonus'; import { HomeworldTupileCleanup } from './homeworld-tupile-cleanup'; import { TupileIntelligence } from './tupile-intelligence'; import { NexusCards } from './nexus-cards'; import { NexusTraitors } from './nexus-traitors'; import { GreatMakerDecision } from './great-maker'; import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries'; import { DiscoveryEntryDecision } from './discovery-entry'; import { DiscoveryStormDecision } from './discovery-storm'; import { JacurutuDial } from './jacurutu-dial'; import { NexusChoamTrade, NexusChoamInspectionDecision, NexusChoamInspectionInsight } from './nexus-choam-trade'; import { NexusChoamBetrayal } from './nexus-choam-betrayal'; import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal'; import { buildRevision, buildRevisionLabel } from '@/lib/build-revision'; import { NexusEcazBetrayal } from './nexus-ecaz-betrayal'; import { NexusFremenBetrayal } from './nexus-fremen-betrayal'; import { NexusFremenRideBetrayal } from './nexus-fremen-ride-betrayal'; import { NexusEcazInquiry } from './nexus-ecaz-inquiry'; import { NexusEcazDuke } from './nexus-ecaz-duke'; import { NexusTleilaxu } from './nexus-tleilaxu'; import { NexusSuboids } from './nexus-suboids'; import { NexusAdvisors } from './nexus-advisors'; import { NexusSardaukar } from './nexus-sardaukar'; import { TerrorBoardMarkers } from './terror-board-markers'; import { AmbassadorBoardMarkers } from './ambassador-board-markers'; import { CharityClaim } from './charity-claim'; import { FremenRevivalGrant } from './fremen-revival-grant'; import { AdvisorPreparation } from './advisor-preparation'; import { AtreidesFullPlan } from './atreides-full-plan'; import { TleilaxuSpecialKarama } from './tleilaxu-special-karama'; import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options'; import { GuildHomeworldShipment } from './guild-homeworld-shipment'; import { JunctionTransport } from './junction-transport'; import { BiddingEnd } from './bidding-end'; import { HomeworldTable, NativeShipmentChoice, EmperorHomeworldMovement, } from './homeworld-table'; import { nativeShipmentSources } from '@/game/homeworld-options'; import { botBattleChoices } from '@/game/bot-battle-choices'; import { specialForceName, maxCombatDial, maxCombatSupport } from '@/game/combat'; import type { NativeReserveSelections } from '@/game/homeworld-native-reserves'; import { reinforcementsBattleForces } from '@/game/reinforcements-homeworld'; import { ReinforcementsSources, reinforcementsSourceLabel } from './reinforcements-sources'; import { StrongholdCardGallery, StrongholdCopyChoice, } from './stronghold-cards'; import { EcazPlacement, AmbassadorSupply } from './ecaz-ambassadors'; import { EcazEntry, AmbassadorInsights } from './ecaz-entry'; import { EcazSpecialKarama } from './ecaz-special-karama'; import { MoritaniSpecialKarama } from './moritani-special-karama'; import { RicheseAuctionDecision, RicheseAuctionLot } from './richese-auctions'; import { RicheseSpecialKarama } from './richese-special-karama'; import { RicheseGift } from './richese-gift'; import { Distrans } from './distrans'; import { JuiceOfSapho } from './juice-of-sapho'; import { BattleOutcomeWindowControls } from './battle-outcome-window'; import { LeaderSkillsPanel } from './leader-skills'; import { LeaderSkillBattleGuide } from './leader-skill-battle-guide'; import { smugglerBattlePlanBlock } from '@/game/smuggler-battle'; import { SmugglerBattleCollection } from './smuggler-battle-collection'; import { SandmasterVictorySpice } from './sandmaster-victory-spice'; import { RihaniChoice, RihaniHistory } from './rihani-decipherer'; import { MentatQuestion, MentatHistory } from './mentat-question'; import { BureaucratPayment } from './bureaucrat-payment'; import { Bribes } from './bribes'; import { PublicDeals } from './public-deals'; import { SukGraduatePanel } from './suk-graduate'; import { NullentropyBox, NullentropySearch } from './nullentropy-box'; import { SemutaReaction } from './semuta-reaction'; import { ChoamKull } from './choam-kull'; import { NexusRicheseBetrayal } from './nexus-richese-betrayal'; import { NexusGuildBetrayal } from './nexus-guild-betrayal'; import { NexusIxianReplacement } from './nexus-ixian-replacement'; import { NexusIxianBetrayal } from './nexus-ixian-betrayal'; import { NexusHarkonnenBetrayal } from './nexus-harkonnen-betrayal'; import { OrnithopterMovement } from './ornithopter-movement'; import { DiscoveryOrnithopterMovement } from './discovery-flight-movement'; import { PlanetologistMovement } from './planetologist-movement'; import { selectedOriginElites, advisorFlipMovementBlocked } from '@/game/planetologist-movement'; import { SandmasterMovement } from './sandmaster-movement'; import { SandmasterWormChoice } from './sandmaster-worm'; import { sandmasterWormCollection, sandmasterCunningCollection } from '@/game/sandmaster-worm'; import { fremenCunningPublicDestinationBlock } from '@/game/nexus-fremen-cunning'; import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison'; import { PortableSnooper } from './portable-snooper'; import { RicheseNoFieldControls, NoFieldBoardMarkers, } from './richese-no-field'; import { RicheseAlliedNoFieldControls, RicheseAlliedNoFieldDecision, } from './richese-allied-no-field'; import { presenceByLocation } from '@/game/force-presence'; import { AutomaticActionNotice } from './automatic-action-notice'; import { RoomControlNotice } from './room-control-notice'; import { TableSounds } from './table-sounds'; import { EcazSpice } from './ecaz-spice'; import { VictoryProgress } from './victory-progress'; import { VictoryContinuation } from './victory-continuation'; import { tableActionOwner } from '@/game/table-turn'; import { DukeVidal } from './duke-vidal'; import { CardInspector, CardRules } from './card-inspector'; import { HarkonnenExchangeInspection } from './harkonnen-exchange-inspection'; import { Recruits } from './recruits'; import { ecazTreacheryDefinition } from '@/game/ecaz-cards'; import { ordinaryCardAvailability } from '@/game/card-availability'; import { cardPresentation, richeseCardActionBlock, } from '@/game/card-presentation'; import { richeseCardDefinition } from '@/game/richese-cards'; import { SpiceCardInspector, spiceCardTitle } from './spice-card-inspector'; import { BattleWheel } from './battle-wheel'; import { LeaderInspector, type LeaderDisplayIdentity, type LeaderInspectorProps, } from './leader-inspector'; import { ChoamStorm } from './choam-storm'; import { ChoamBaliset } from './choam-baliset'; import { ChoamGamont } from './choam-gamont'; import { ChoamWorthless } from './choam-worthless'; import { ChoamBattleFunding } from './choam-battle-funding'; import { AuditorDecision, AuditorInsight } from './choam-auditor'; import { ChoamCashIn } from './choam-cash-in'; import { ChoamMarket } from './choam-market'; import { BattlePromises } from './battle-promises'; import { Truthtrance, TruthHistory } from './truthtrance'; import { TruthCharityPromise } from './truth-charity-promise'; import { forceRevivalPrice, eliteRevivalRemaining, paidForceRevivalCost, } from '@/game/revival'; import { IxTechnology } from './ix-technology'; import { MobileStronghold } from './mobile-stronghold'; import { IxSubstitution } from './ix-substitution'; import { IxHomeworldRevivalBonus } from './ix-homeworld-revival'; import { controlsLeader } from '@/game/leader-control'; import { leaderStrengthLabel, harvesterAvailable } from '@/game/cards'; import { RevivalCommerce } from './revival-commerce'; import { RevivalTurn } from './revival-turn'; import { FaceDanceDecision } from './face-dance-decision'; import { CHEAP_HERO_TRAITOR, matchingTraitor } from '@/game/traitors'; import { battleCardLabel, isStoneBurner, isMirrorWeapon, } from '@/game/battle-cards'; import { validBattleSlotPair, usesNativeBattleSpecial } from '@/game/battle-card-slots'; import { stoneBurnerPlanBlock, stoneBurnerWithdrawalContext } from '@/game/stone-burner'; import { StoneBurnerAllocation } from './stone-burner-allocation'; import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode'; import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens'; import { canUseAsKaramaRole, canUseAsTruthtranceRole, } from '@/game/shrine'; import { kullPurposeBlock, kullSpecialPurpose, type KullPurpose } from '@/game/kull-purpose'; import { LeaderPortrait } from './leader-portrait'; import { ShipmentQuote } from './shipment-quote'; import { groupGuildShipmentRate, reserveShipmentCost } from '@/game/shipment-price'; import { quoteSmugglerShipment } from '@/game/smuggler-shipment'; import { SmugglerShipmentChoice } from './smuggler-shipment'; import { nexusGuildSecretAllyAction, nexusGuildSecretAllyQuote } from '@/game/nexus-guild-secret-ally-options'; import { nexusRicheseAction, nexusRicheseQuote } from '@/game/nexus-richese-options'; import { guildTransportQuote } from '@/game/transport-quote'; import { fremenReserveEntry, botGroundMoveAllowed, botMovementRange, groundMoveRouteDistance } from '@/game/bot-mobility'; import { isAlliedShipmentVisit } from '@/game/allied-separation'; import { location as boardLocation } from '@/game/board'; import { isBoardNoFieldLocation } from '@/game/richese-no-field'; import { fighterCount, isAdvisor } from '@/game/advisors'; import { useState } from 'react'; import { ArrowRight, Wind, Coins, Copy, Check, Swords, ZoomIn, ZoomOut, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { BattlePreparation, battlePlanCommitments, battlePlanCardValue, battlePlanCardOptions, bindBattlePlanCommitments, NexusInspectionHistory } from './battle-preparation'; import { HarassWithdrawGuide, harassWithdrawControlState } from './harass-withdraw'; import { HarassWithdrawChoice } from './harass-withdraw-choice'; import { PrivateBattlePlan } from './private-battle-plan'; import { SpiceBankerControl, spiceBankerControlState } from './spice-banker'; import { SpiceBankerIncome } from './spice-banker-income'; import { canUsePlanetologistBattleSpecial, planetologistSpecialNeedsChoice } from '../game/leader-skill-combat'; import type { Card } from '../game/cards'; import { RevealedBattle } from './revealed-battle'; import { DiplomatDefense } from './diplomat-defense'; import { DiplomatRetreat } from './diplomat-retreat'; import { EliteCount } from './elite-count'; import { HelpTip } from './help-tip'; import { PHASE_HELP, phaseRuleId } from '@/game/reference'; import { DIFFICULTIES, BOT_DESCRIPTIONS, type Difficulty, } from '@/game/bot-profiles'; import { FACTIONS, PHASES, faction } from '@/game/catalog'; import { FREMEN_START_LOCATIONS, TERRITORIES, gameTerritories, MOBILE_STRONGHOLD, territory, splitLocation, } from '@/game/board'; import type { Action, GameView, Plan } from '@/game/engine'; import { AuctionCountdown } from './auction-countdown'; import { RicheseAuctionCountdown } from './richese-auction-countdown'; import { PLAYER_CIRCLE_SECTORS } from '@/game/player-positions'; import { SeatAutopilot } from './seat-autopilot'; import { MoritaniEntry, MoritaniPlacement, MoritaniTerrorSupply, } from './moritani-terror'; import { TERROR_DEFINITIONS } from '@/game/moritani-terror'; import { MoritaniRetention } from './moritani-retention'; import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assassinate'; import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion'; import { AdvancedAllySeparation } from './advanced-ally-separation'; import { CoalitionNexus } from './coalition-nexus'; import { RosterAlliance } from './roster-alliance'; import { CoalitionFunding, coalitionFundingContributions, type CoalitionFundingContribution } from './coalition-funding'; import { CoalitionEmperorAid } from './coalition-emperor-aid'; import { NexusEmperorPayment } from './nexus-emperor-payment'; import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase'; import { NexusEmperorAmbassadorPurchase } from './nexus-emperor-ambassador-purchase'; export function GameTable({ game: g, send, busy: transportBusy, onExit, exitDisabled = false, }: { game: GameView; send: (a: Action) => Promise; busy: boolean; onExit: () => void; exitDisabled?: boolean; }) { const me = g.players.find((p) => p.id === g.me)!; const groupNexus = g.coalitionNexus; const inGroup = groupNexus?.groups.some(group => group.members.length >= 3 && group.members.includes(me.id)); const outgoingAlliance = g.allianceOffers[me.id]; const outgoingAllianceName = g.players.find((p) => p.id === outgoingAlliance)?.name; const shipmentAvailable = nexusGuildShipmentAvailable(g); const movementAvailable = nexusGuildMovementAvailable(g); const guildCunning = nexusGuildCunningActive(g); const guildCunningAction = g.nexusGuildCunning?.offer ? nexusGuildCunningAction(g, g.nexusGuildCunning.offer.event) : null; const coreNexusPreview = nexusCoreProfile(g) && (g.guildBetrayalPreview || g.nexusIxianReplacementPreview || g.nexusHarkonnenBetrayalPreview || g.nexusIxianBetrayalPreview); const combatName = (id: string) => g.combatLocations?.find((place) => place.id === id)?.name ?? gameTerritories(g).find((place) => place.id === id)?.name ?? id; const turnOwner = g.victoryContinuation?.actor ?? tableActionOwner(g); const turnOwnerName = g.players.find((p) => p.id === turnOwner)?.name; const setupStage = g.setupStage; const setupFremen = g.players.find((p) => p.faction === 'fremen'); const setupEcaz = g.players.find((p) => p.faction === 'ecaz'); const setupSisterhood = g.players.find((p) => p.faction === 'beneGesserit'); const setupPendingNames = (g.setupPending ?? []) .filter((id) => id !== me.id) .map((id) => g.players.find((p) => p.id === id)?.name) .filter(Boolean) .join(', '); const waitingForFremen = !!setupFremen && setupFremen.reserves !== 10; const waitingForEcaz = setupStage === 'forces' && !!setupEcaz && setupEcaz.reserves === 20; const setupStatus = setupStage === 'leaderSkills' ? g.leaderSkills?.offer ? 'Keep one of your two Leader Skill cards and assign it to an eligible leader. Traitor selection follows when every faction has chosen.' : `Your skill is assigned. Waiting for ${setupPendingNames || 'the remaining players'} to choose.` : setupStage === 'prediction' ? setupSisterhood?.id === me.id ? 'Seal your secret prediction before traitors or treachery cards are dealt.' : `Waiting for ${setupSisterhood?.name ?? 'Bene Gesserit'} to seal the secret prediction.` : setupStage === 'traitors' ? me.traitorChoices?.length ? 'Choose one of your dealt traitors. Every player completes this step before starting forces are placed.' : setupPendingNames ? `Your traitors are settled. Waiting for ${setupPendingNames} to choose.` : 'Your traitors are settled. The remaining selections are finishing.' : setupStage === 'forces' ? waitingForFremen ? setupFremen.id === me.id ? g.advanced && setupSisterhood ? 'Place your ten starting forces. Bene Gesserit places its advisor afterward.' : g.leaderSkills ? 'Place your ten starting forces. Starting cards were already dealt before skill assignment.' : 'Place your ten starting forces. Starting cards are dealt automatically after placement.' : `Waiting for ${setupFremen.name} to place the Fremen starting forces.` : waitingForEcaz ? setupEcaz.id === me.id ? 'Place your six starting forces among the sectors of Imperial Basin. Fourteen remain in reserves.' : `Waiting for ${setupEcaz.name} to place the Ecaz starting forces.` : g.advanced && setupSisterhood && !setupSisterhood.advisorSetup ? setupSisterhood.id === me.id ? 'Choose a printed board territory and sector for your starting advisor.' : `Waiting for ${setupSisterhood.name} to place the starting advisor.` : g.leaderSkills ? 'Starting placements are finishing. The Storm phase opens automatically.' : 'Starting placements are finishing. Cards are dealt and the Storm phase opens automatically.' : 'Complete your starting choices. Play begins when everyone is ready.'; const automaticEvents = g.log.flatMap((entry) => entry.automatic ? [ { id: `${g.code}:${entry.seq}`, seq: entry.seq, faction: entry.automatic.faction, name: entry.automatic.name, }, ] : [], ); const reactionBusy = transportBusy || !!g.factionDraft || !!g.factionSwap || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot; const ixianBetrayalOwnsControls = !!g.nexusIxianBetrayalReaction && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && !g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending; const harkonnenBetrayalOwnsControls = !!g.nexusHarkonnenBetrayalReaction && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && !g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending; const busy = reactionBusy || !!g.victoryContinuation || !!g.nexusFremenRideBetrayal || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; const reactionOwnsControls = !!g.nexusFremenRideBetrayal || !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; const canSpendKarama = (card: Card, purpose: KullPurpose) => !kullPurposeBlock(g, purpose) && canUseAsKaramaRole(g, me, card); const canActivateKarama = (card: Card) => canSpendKarama(card, kullSpecialPurpose(me.faction)); const traitorBattle = g.battle; const traitorBeneficiary = traitorBattle?.traitorBeneficiary ?? (traitorBattle && [traitorBattle.attacker, traitorBattle.defender].includes(me.id) ? me.id : me.ally); const traitorPlan = traitorBattle?.revealed ? traitorBattle.plans[ traitorBattle.attacker === traitorBeneficiary ? traitorBattle.defender : traitorBattle.attacker ] : undefined; const canRevealTraitor = traitorBattle?.traitorCallChoices?.includes(true) ?? !!( traitorBattle?.traitorVoters.includes(me.id) && traitorPlan && !traitorPlan.kwisatz && matchingTraitor( me.traitors ?? [], traitorPlan.leader, traitorBattle.cards.find((card) => card.id === traitorPlan.leader), ) ); const canDeclineTraitor = traitorBattle?.traitorCallChoices?.includes(false) ?? true; const noTraitorReason = traitorBattle?.ownTraitorPromises?.some(promise => !promise.answer && !promise.fulfilled && !promise.released) || (!canRevealTraitor && canDeclineTraitor && g.mixedTraitorPromises.some(promise => promise.battle === traitorBattle?.event && !promise.fulfilled && !promise.released)) ? 'Your accepted Truthtrance answer requires declining this Traitor declaration.' : traitorPlan?.kwisatz ? 'Kwisatz Haderach prevents this leader from turning traitor.' : 'You do not hold the opposing leader as a traitor.'; const [guildSecretEvent, setGuildSecretEvent] = useState(''); const useGuildSecret = !!g.nexusGuildSecretAlly && guildSecretEvent === g.nexusGuildSecretAlly.event; const guildTransport = useGuildSecret || me.faction === 'guild' || groupGuildShipmentRate(g.players, me.id, g.coalitionState) || g.players.some((p) => p.faction === 'guild' && p.id === me.ally); const [botFaction, setBotFaction] = useState(''); const [botDifficulty, setBotDifficulty] = useState('Medium'); const availableBotFactions = FACTIONS.filter( (f) => (f.expansion === 'base' || g.expansions.includes(f.expansion)) && !g.players.some((p) => p.faction === f.id), ); const nextBotFaction = availableBotFactions.some((f) => f.id === botFaction) ? botFaction : (availableBotFactions[0]?.id ?? ''); const [selectedId, setSelected] = useState(() => gameTerritories(g).some((location) => location.id === g.battle?.territory) ? g.battle!.territory : 'arrakeen', ); const selected = gameTerritories(g).some((location) => location.id === selectedId) ? selectedId : 'arrakeen'; const [sector, setSector] = useState(10); const [amount, setAmount] = useState(1); const [richeseShipmentEvent, setRicheseShipmentEvent] = useState(''); const useRicheseShipment = !!g.nexusRichese && richeseShipmentEvent === g.nexusRichese.event; const [useSmuggler, setUseSmuggler] = useState(true); const [bidDraft, setBidDraft] = useState({ auction: '', value: '' }); const minimumBid = (g.auction?.bid ?? 0) + 1; const auctionKey = `${g.code}/${g.turn}/${g.auction?.remaining ?? 0}/${minimumBid}`; const bidText = bidDraft.auction === auctionKey ? bidDraft.value : String(minimumBid); const bidAmount = integerInputValue(bidText); const [eliteAmount, setEliteAmount] = useState(0); const [homeworldDraft, setHomeworldDraft] = useState<{ key: string; sources: NativeReserveSelections; } | null>(null); const homeworldSourceKey = JSON.stringify([ g.code, g.turn, g.phase, g.active, g.homeworlds, amount, eliteAmount, ]); const homeworldSources = homeworldDraft?.key === homeworldSourceKey ? homeworldDraft.sources : nativeShipmentSources(g, amount, eliteAmount); const [freeCyborgFirst, setFreeCyborgFirst] = useState(true); const [allyEliteAmount, setAllyEliteAmount] = useState(0); const [eliteForces, setEliteForces] = useState>({}); const [eliteSetup, setEliteSetup] = useState>({}); const [moveAsFighters, setMoveAsFighters] = useState(false); const [source, setSource] = useState(''); const [target, setTarget] = useState(''); const [leader, setLeader] = useState(''); const [weapon, setWeapon] = useState(''); const [planetologistDraft, setPlanetologistDraft] = useState<{ key: string; enabled: boolean } | null>(null); const [defense, setDefense] = useState(''); const [dial, setDial] = useState(0); const [stormDial, setStormDial] = useState(0); const stormDialValue = Math.max( g.turn === 1 ? 0 : 1, Math.min( g.turn === 1 ? 20 : 3, Number.isFinite(stormDial) ? Math.trunc(stormDial) : 0, ), ); const selectedWeaponId = battlePlanCardValue(g, 'weapon', weapon); const selectedDefenseId = battlePlanCardValue(g, 'defense', defense); const selectedBattleWeapon = me.hand?.find(c => c.id === selectedWeaponId); const selectedBattleDefense = me.hand?.find(c => c.id === selectedDefenseId); const committed = battlePlanCommitments(g); const selectedBattleLeader = committed.leader ? String(committed.leader.value ?? '') : leader; const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? []; const planetologistSpecial = (card: Card) => canUsePlanetologistBattleSpecial({ assignments: battleSkills, selectedLeader: selectedBattleLeader, card, }); const planetologistRoleKey = `${g.battle?.event ?? ''}:${selectedBattleLeader}:${selectedWeaponId}`; const planetologistEligible = !!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon); const planetologistRoleChoice = planetologistEligible && planetologistSpecialNeedsChoice(selectedBattleWeapon); const planetologistAlternate = planetologistRoleChoice && planetologistDraft?.key === planetologistRoleKey && planetologistDraft.enabled; const planetologistWeapon = planetologistEligible && (!planetologistRoleChoice || planetologistAlternate); const reinforcementsSelected = (selectedWeaponId === 'ecaz-reinforcements' && !planetologistWeapon) || selectedDefenseId === 'ecaz-reinforcements'; const harassSelected = (selectedWeaponId === 'ecaz-harass-withdraw' && !planetologistWeapon) || selectedDefenseId === 'ecaz-harass-withdraw'; const [reinforcementsEliteDraft, setReinforcementsEliteDraft] = useState<{ key: string; elite: number } | null>(null); const reinforcementsEliteKey = `${g.code}:${me.id}:${g.battle?.event ?? ''}`; const reinforcementsEliteOptions = g.battle?.reinforcements?.eliteOptions ?? []; const chosenReinforcementsElite = reinforcementsEliteDraft?.key === reinforcementsEliteKey && reinforcementsEliteOptions.includes(reinforcementsEliteDraft.elite) ? reinforcementsEliteDraft.elite : reinforcementsEliteOptions[0]; const [reinforcementsSourceDraft, setReinforcementsSourceDraft] = useState<{ key: string; index: number; } | null>(null); const reinforcementsSourceChoices = g.battle?.reinforcements?.homeworldSources; const reinforcementsSourceKey = reinforcementsSourceChoices ? `${g.code}:${me.id}:${g.battle?.event ?? ''}:${JSON.stringify(reinforcementsSourceChoices)}` : ''; const reinforcementsSourceIndex = reinforcementsSourceDraft?.key === reinforcementsSourceKey ? Math.min(reinforcementsSourceDraft.index, Math.max(0, (reinforcementsSourceChoices?.length ?? 1) - 1)) : 0; const selectedReserveSource = reinforcementsSourceChoices?.[reinforcementsSourceIndex]; const rawOwnBattleForces = g.battle?.ownForces; const ownBattleForces = rawOwnBattleForces && reinforcementsSelected && selectedReserveSource && g.battle?.native === me.id ? reinforcementsBattleForces(rawOwnBattleForces, selectedReserveSource[g.battle!.territory]) : rawOwnBattleForces; const occupyProfile = ecazOccupyOwnProfile(g); const battleDialMinimum = occupyProfile?.fixedEcazDial ?? 0; const battleFreeSupport = ownBattleForces?.freeSupport ?? (me.faction === 'fremen' && !g.battle?.fremenSupportBlocked); const battleForceOwnerName = occupyProfile ? g.players.find((player) => player.id === occupyProfile.forceOwner)?.name ?? occupyProfile.forceOwner : me.name; const battleForces = ownBattleForces ? ownBattleForces.normal + ownBattleForces.elite : g.battle ? fighterCount(me, g.battle.territory) : 0; const battleDialMaximum = occupyProfile?.maxDial ?? (ownBattleForces ? maxCombatDial(ownBattleForces) : g.advanced || me.faction === 'ixians' ? 40 : battleForces); const battleDialStep = g.advanced || ownBattleForces?.normalFixedHalf || me.faction === 'ixians' ? 0.5 : 1; // A draft carried from a previous battle must show and submit the same bounded value. const battleDial = Number.isFinite(dial) ? Math.max( battleDialMinimum, Math.min( Math.round(dial / battleDialStep) * battleDialStep, battleDialMaximum, ), ) : battleDialMinimum; const [support, setSupport] = useState(0); const [bankerSpice, setBankerSpice] = useState(0); const strongholdBankSupport = g.battle?.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0; const battleSupportMaximum = Math.min( (me.spice ?? 0) + g.aid.available + strongholdBankSupport, ownBattleForces ? maxCombatSupport(ownBattleForces) : Number.POSITIVE_INFINITY, ); const battleSupport = Number.isFinite(support) ? Math.max(0, Math.min(Math.trunc(support), battleSupportMaximum)) : 0; const [kwisatz, setKwisatz] = useState(false); const [weatherDistance, setWeatherDistance] = useState(0); const [turn, setTurn] = useState(1); const [copied, setCopied] = useState(false); const [zoom, setZoom] = useState(1); const [panel, setPanel] = useState<'hand' | 'leaders' | 'log' | 'rules'>( 'hand', ); const [fremen, setFremen] = useState>({ [FREMEN_START_LOCATIONS[0]]: 10, }); const [aidAmount, setAidAmount] = useState(0); const [allyPayment, setAllyPayment] = useState(''); const [coalitionShares, setCoalitionShares] = useState<{key:string; shares:CoalitionFundingContribution[]}>({key:'', shares:[]}); const fillBattlePlan = (plan: Plan) => { setDial(plan.dial); setSupport(plan.support); setBankerSpice(plan.bankerSpice ?? 0); setAllyPayment(plan.allyPayment === undefined ? '' : String(plan.allyPayment)); setLeader(plan.leader ?? ''); setWeapon(plan.weapon ?? ''); setDefense(plan.defense ?? ''); setPlanetologistDraft({ key: `${g.battle?.event ?? ''}:${plan.leader ?? ''}:${plan.weapon ?? ''}`, enabled: plan.planetologistSpecial === true, }); setKwisatz(!!plan.kwisatz); setReinforcementsEliteDraft(plan.reinforcementsElite === undefined ? null : { key: reinforcementsEliteKey, elite: plan.reinforcementsElite }); if (plan.reinforcementsSources) { const chosen = JSON.stringify(plan.reinforcementsSources); const index = reinforcementsSourceChoices?.findIndex(choice => JSON.stringify(choice) === chosen); if (index !== undefined && index >= 0) setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index }); } }; const [combineSectors, setCombineSectors] = useState(false); const [moveNoField, setMoveNoField] = useState(false); const [moveForces, setMoveForces] = useState>({}); const [rideForces, setRideForces] = useState>({}); const [collectSandmasterRide, setCollectSandmasterRide] = useState(true); const [sandmasterRidePile, setSandmasterRidePile] = useState<{ target: string; key: string } | null>(null); const sandmasterRideTarget = JSON.stringify([g.turn, g.decision?.kind === 'wormRide' || g.decision?.kind === 'nexusFremenCunningRide' ? g.decision : null, source, selected, sector]); const baseSandmasterRide = g.decision?.kind === 'nexusFremenCunningRide' ? sandmasterCunningCollection(g, me.id, source, selected, sector) : sandmasterWormCollection(g, me.id, selected, sector); const selectedRidePile = sandmasterRidePile?.target === sandmasterRideTarget ? baseSandmasterRide?.piles.find(pile => pile.key === sandmasterRidePile.key) : null; const sandmasterRide = selectedRidePile && baseSandmasterRide ? { ...baseSandmasterRide, key: selectedRidePile.key, before: selectedRidePile.before } : baseSandmasterRide; const cunningDestinationBlocked = g.decision?.kind === 'nexusFremenCunningRide' && source ? fremenCunningPublicDestinationBlock(g, me.id, source, { territory: selected, sector }) : null; const [exchangeCards, setExchangeCards] = useState>( {}, ); const [discardCards, setDiscardCards] = useState>({}); const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null; const paidBattleSupport = Math.max(0, battleSupport - strongholdBankSupport); const selectedBattleAllyPayment = allyPayment === '' ? Math.max(0, paidBattleSupport - (me.spice ?? 0)) : Math.max( 0, Math.min( Math.trunc(Number(allyPayment)), paidBattleSupport, g.aid.available, ), ); const ownBattleSupport = Math.max( 0, paidBattleSupport - selectedBattleAllyPayment, ); const bankerControl = spiceBankerControlState( g, selectedBattleLeader, ownBattleSupport, ); const battleBankerSpice = Math.max( 0, Math.min(Math.trunc(bankerSpice), bankerControl.maximum), ); const reinforcementsPlanReason = !reinforcementsSelected ? null : !g.battle?.reinforcements ? 'Reinforcements is unavailable in this battle.' : g.battle.reinforcements.blocked ?? (g.homeworlds && !selectedReserveSource ? 'Choose the exact native Reinforcements reserve sources.' : null); const selectedBattleKwisatz = kwisatz && !!me.kwisatz?.active && !me.kwisatz.dead && !g.battle?.kwisatzBlocked && (!me.kwisatz.usedAt || me.kwisatz.usedAt === g.battle?.territory); const smugglerPlanReason = g.battle?.smugglerCollectionEnabled ? smugglerBattlePlanBlock({ assignments: g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [], leader: me.leaders.find(l => l.id === selectedBattleLeader), weapon: selectedBattleWeapon, defense: selectedBattleDefense, kwisatz: selectedBattleKwisatz, }, g.battle.smugglerCollectionSupported, { territory: g.battle.territory, spice: g.spice }) : null; const occupyControl = ecazOccupyPlanControl(g, committed.dial ? Number(committed.dial.value) : battleDial, battleFreeSupport ? 0 : battleSupport); const battlePairValid = validBattleSlotPair(selectedBattleWeapon, selectedBattleDefense, planetologistWeapon); const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw, harassSelected, committed.dial ? Number(committed.dial.value) : battleDial, battleFreeSupport ? 0 : battleSupport); const selectedSpecialWeapon = me.hand?.find( (c) => c.id === selectedWeaponId && (isStoneBurner(c) || isMirrorWeapon(c)), ); const mirrorSelected = isMirrorWeapon(selectedSpecialWeapon); const stoneContext = g.battle?.stoneBurnerContext; const stoneCandidateContext = selectedSpecialWeapon && harassSelected ? stoneBurnerWithdrawalContext(stoneContext?.occupy, g.battle!.attacker === me.id ? 'attacker' : 'defender', g.battle?.harassWithdraw?.occupy) : stoneContext?.occupy; const stonePlanReason = !selectedSpecialWeapon ? null : ((mirrorSelected ? mirrorWeaponModeBlock(g) : null) ?? stoneContext?.blocked ?? (!ownBattleForces || !stoneContext?.opponentPools.length ? 'Stone Burner needs a supported physical force pool.' : (stoneContext.opponentPools .map((pool) => stoneBurnerPlanBlock( ownBattleForces, committed.dial ? Number(committed.dial?.value) : battleDial, battleSupport, pool, g.battle!.attacker === me.id ? 'attacker' : 'defender', g.battle!.tieWinner === g.battle!.attacker ? 'attacker' : 'defender', stoneCandidateContext, stoneContext?.allowAllocations, ), ) .find((reason) => reason !== null) ?? null))); const payment = !me.ally || allyPayment === '' ? {} : { allyPayment: Number(allyPayment) }; const shipmentProblems: string[] = []; if (useGuildSecret && g.nexusGuildSecretAlly?.blocked) shipmentProblems.push(g.nexusGuildSecretAlly.blocked); if (useRicheseShipment && g.nexusRichese?.blocked) shipmentProblems.push(g.nexusRichese.blocked); const shipmentMaximum = useRicheseShipment ? Math.min(me.reserves, 5, g.nexusRichese!.maxForces) : me.reserves; if (homeworldSources) { const homes = g.homeworlds!.worlds!.filter((w) => w.native === me.id); if ( homes.some((home) => (['normal', 'elite'] as const).some((kind) => { const n = homeworldSources[home.id]?.[kind]; return ( !Number.isSafeInteger(n) || n < 0 || n > home.forces[me.id][kind] ); }), ) ) shipmentProblems.push( 'Choose available whole numbers of normal forces and Sardaukar from each Homeworld.', ); } if ( liveShipmentPromises(g.shipmentPromises, me.id, g.turn).some( (p) => matchesShipment(p, { territory: selected, amount }) !== p.answer, ) ) shipmentProblems.push( 'This shipment conflicts with your Truthtrance answer. Choose a count and destination that honor your shipment promises.', ); const selectedDestinationInStorm = sector !== 0 && sector === g.storm; if (selectedDestinationInStorm && !(!useGuildSecret && g.advanced && me.faction === 'fremen')) shipmentProblems.push( `Sector ${sector} of ${territory(selected).name} is in the storm. Choose a different sector or territory.`, ); if (!useGuildSecret && me.faction === 'fremen' && !fremenReserveEntry(selected)) shipmentProblems.push( `${territory(selected).name} is beyond the Fremen reinforcement area. Choose the Great Flat or a territory within two territories of it.`, ); const validShipmentAmount = Number.isInteger(amount) && amount > 0 && amount <= shipmentMaximum; if (!validShipmentAmount) shipmentProblems.push( `Choose a whole number from 1 to ${shipmentMaximum} available reserves.`, ); if (me.elites && validShipmentAmount) { const minimum = Math.max(0, amount - (me.reserves - me.elites.reserves)); const maximum = Math.min(amount, me.elites.reserves); if ( !Number.isInteger(eliteAmount) || eliteAmount < minimum || eliteAmount > maximum ) shipmentProblems.push( `Choose ${minimum} to ${maximum} elite forces from reserves.`, ); } const groupGuildRate = groupGuildShipmentRate(g.players, me.id, g.coalitionState); const shipmentRate = { faction: me.faction, halfRate: g.karamaShipping?.player === me.id || (!g.guildRateCanceled && (me.faction === 'guild' || groupGuildRate || g.players.some((p) => p.faction === 'guild' && p.id === me.ally))), }; const smugglerOffer = validShipmentAmount && !useGuildSecret && !useRicheseShipment ? quoteSmugglerShipment(g, me.id, selected, amount) : null; const smugglerShipment = useSmuggler ? smugglerOffer : null; const shipmentCost = validShipmentAmount ? useGuildSecret ? (nexusGuildSecretAllyQuote(g, selected, amount)?.cost ?? null) : useRicheseShipment ? (nexusRicheseQuote(g, selected, amount)?.cost ?? null) : reserveShipmentCost(shipmentRate, territory(selected).type, amount - (smugglerShipment ? 1 : 0)) : null; if (useGuildSecret && shipmentCost === null && !g.nexusGuildSecretAlly?.blocked) shipmentProblems.push('Choose a current Guild Secret Ally shipment.'); if (useRicheseShipment && shipmentCost === null && !g.nexusRichese?.blocked) shipmentProblems.push('Choose a current Richese Secret Ally shipment of up to five physical forces.'); const coalitionFundingKey = JSON.stringify([g.code, g.turn, g.phase, g.coalitionFunding?.incoming.map(({donor, amount}) => [donor, amount]) ?? []]); const currentCoalitionShares = g.coalitionFunding && coalitionShares.key === coalitionFundingKey ? coalitionFundingContributions(g.coalitionFunding.incoming, Object.fromEntries(coalitionShares.shares.map(share => [share.donor, share.amount]))) : []; const groupShipmentShare = currentCoalitionShares.reduce((sum, share) => sum + share.amount, 0); const availableShipmentAid = g.coalitionFunding ? g.coalitionFunding.incoming.reduce((sum, share) => sum + share.amount, 0) : g.aid.available; const maximumBid = me.hand?.some((c) => canUseAsKaramaRole(g, me, c)) ? Number.MAX_SAFE_INTEGER : (me.spice ?? 0) + (g.phase === 3 && g.coalitionFunding ? groupShipmentShare : g.aid.available); const selectedAllyPayment = shipmentCost === null ? 0 : g.coalitionFunding ? groupShipmentShare : !me.ally || allyPayment === '' ? Math.max(0, shipmentCost - (me.spice ?? 0)) : Number(allyPayment); const validShipmentShare = shipmentCost !== null && Number.isInteger(selectedAllyPayment) && selectedAllyPayment >= 0 && selectedAllyPayment <= shipmentCost; if (shipmentCost !== null) { if (!validShipmentShare) shipmentProblems.push( `Ally payment must be a whole number from 0 to ${shipmentCost}.`, ); else { if (selectedAllyPayment > availableShipmentAid) shipmentProblems.push( `Only ${availableShipmentAid} pledged ally spice is available.`, ); if (shipmentCost - selectedAllyPayment > (me.spice ?? 0)) shipmentProblems.push( 'Your spice does not cover your selected share. Adjust the ally payment or ship fewer forces.', ); } } const shipmentQuote = validShipmentShare ? { cost: shipmentCost!, normalCost: reserveShipmentCost( { ...shipmentRate, halfRate: false }, territory(selected).type, amount, ), ownPayment: shipmentCost! - selectedAllyPayment, pledgedPayment: selectedAllyPayment, } : null; const physicalMovementGroup = combineSectors ? { eliteForces, forces: Object.fromEntries( Object.entries(moveForces).filter( ([key]) => splitLocation(key).territory === splitLocation(source).territory, ), ), } : { from: source, amount, elite: eliteAmount }; const ownedDeployedNoField = g.richeseNoField?.owner === me.id ? g.richeseNoField.private?.deployed : null; const ownNoField = ownedDeployedNoField && isBoardNoFieldLocation(ownedDeployedNoField.location) ? { ...ownedDeployedNoField, location: ownedDeployedNoField.location } : null; const markerAtSource = !!ownNoField && !!source && ownNoField.location.territory === splitLocation(source).territory; const includesNoField = moveNoField && markerAtSource && !g.homeworldMobility?.noFieldMovementBlocked; const physicalDraftEntries = combineSectors ? Object.entries(moveForces).filter( ([key]) => splitLocation(key).territory === splitLocation(source).territory, ) : source ? [[source, amount] as const] : []; const validMarkerGroup = physicalDraftEntries.every( ([key, count]) => Number.isSafeInteger(count) && count >= 0 && count <= (me.forces[key] ?? 0), ); const selectedPhysicalForces = Object.fromEntries( physicalDraftEntries.filter(([, count]) => count > 0), ); const markerPhysicalTotal = Object.values(selectedPhysicalForces).reduce( (total, count) => total + count, 0, ); const movementGroup = includesNoField && ownNoField ? { forces: selectedPhysicalForces, ...(me.elites ? { eliteForces } : {}), noField: ownNoField.tokenId, event: g.richeseNoField!.event, } : physicalMovementGroup; const ordinaryMovementAction: Action = { type: 'move', fighters: moveAsFighters && isAdvisor(me, splitLocation(source).territory), ...movementGroup, territory: selected, sector, ...(includesNoField ? {} : { amount }), }; const advisorFlipRouteBlocked = ordinaryMovementAction.fighters === true && advisorFlipMovementBlocked(g, me); const transportAction: Action = { type: 'guildShip', ...(useGuildSecret ? {nexus: guildSecretEvent} : {}), ...payment, ...(g.coalitionFunding && currentCoalitionShares.length && !useGuildSecret && !includesNoField ? {allyPayment:0, coalitionContributions:currentCoalitionShares} : {}), ...movementGroup, territory: selected, sector, amount, }; const southernTransportAction: Action = { type: 'guildShip', from: 'reserves', elite: eliteAmount, territory: selected, sector, amount, ...payment, ...(g.coalitionFunding && currentCoalitionShares.length ? {allyPayment:0, coalitionContributions:currentCoalitionShares} : {}), }; const returnTransportAction: Action = { type: 'guildShip', ...(useGuildSecret ? {nexus: guildSecretEvent} : {}), ...payment, ...(g.coalitionFunding && currentCoalitionShares.length && !useGuildSecret && !includesNoField ? {allyPayment:0, coalitionContributions:currentCoalitionShares} : {}), ...movementGroup, territory: 'reserves', amount, }; const transportPreview = guildTransport && shipmentAvailable ? guildTransportQuote(g, transportAction) : null; const southernTransportPreview = guildTransport && !useGuildSecret && shipmentAvailable && me.faction === 'fremen' ? guildTransportQuote(g, southernTransportAction) : null; const returnTransportPreview = guildTransport && shipmentAvailable && (me.faction === 'guild' || useGuildSecret) ? guildTransportQuote(g, returnTransportAction) : null; const canceledFremenRouteBlocked = me.fremenMovementBlocked && physicalDraftEntries.some( ([key, count]) => count > 0 && !botGroundMoveAllowed(g, me, key, boardLocation(selected, sector), 0), ); const movementTarget = boardLocation(selected, sector); const selectedMovementElite = combineSectors ? selectedOriginElites(physicalDraftEntries, eliteForces, splitLocation(source).territory) : eliteAmount; const ordinaryMovementRange = botMovementRange(g, me, selectedMovementElite); const ordinaryMovementRouteBlocked = physicalDraftEntries.some(([key, count]) => count > 0 && groundMoveRouteDistance(g, me, key, movementTarget) > ordinaryMovementRange) || (!!includesNoField && !!ownNoField && groundMoveRouteDistance(g, me, boardLocation(ownNoField.location.territory, ownNoField.location.sector), movementTarget) > ordinaryMovementRange); const act = (a: Action) => { // Control of one's own seat is independent of every gameplay response. if (a.type === 'setAutopilot') { void send(a); return; } const harkonnenReaction = g.nexusHarkonnenBetrayalReaction; if (harkonnenReaction && harkonnenBetrayalOwnsControls) { if (reactionBusy || g.status !== 'playing' || !harkonnenReaction.canPass || harkonnenReaction.hasPassed || (a.type !== 'nexusHarkonnenBetrayalPass' && a.type !== 'nexusHarkonnenBetrayalUse') || a.event !== harkonnenReaction.event || (a.type === 'nexusHarkonnenBetrayalUse' && (!harkonnenReaction.canUse || harkonnenReaction.blocked))) return; } const ixianReaction = g.nexusIxianBetrayalReaction; if (ixianReaction && ixianBetrayalOwnsControls) { if (reactionBusy || g.status !== 'playing' || !ixianReaction.canPass || ixianReaction.hasPassed || (a.type !== 'nexusIxianBetrayalPass' && a.type !== 'nexusIxianBetrayalUse') || a.event !== ixianReaction.event || (a.type === 'nexusIxianBetrayalUse' && (!ixianReaction.canUse || ixianReaction.blocked))) return; } const guildReaction = g.guildBetrayalReaction; if (guildReaction) { if (reactionBusy || g.status !== 'playing' || !guildReaction.canPass || guildReaction.hasPassed || (a.type !== 'guildBetrayalPass' && a.type !== 'guildBetrayalUse') || a.event !== guildReaction.event || (a.type === 'guildBetrayalUse' && (!guildReaction.canUse || guildReaction.blocked))) return; } const replacement = g.nexusIxianReplacement; if (replacement && a.type !== 'setAutopilot') { if (reactionBusy || g.status !== 'playing' || !replacement.canPass || replacement.buyer !== me.id || (a.type !== 'nexusIxianReplacementPass' && a.type !== 'nexusIxianReplacementUse') || a.event !== replacement.event || (a.type === 'nexusIxianReplacementUse' && (!replacement.canUse || replacement.blocked))) return; } void send(a); }; const choose = (id: string) => { const location = gameTerritories(g).find((candidate) => candidate.id === id); if (!location) return; setSelected(id); setSector(location.sectors[0]); }; const leaderName = (id: string) => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : (g.allLeaders.find((l) => l.id === id)?.name ?? id); const identityFor = (id: string): LeaderDisplayIdentity | undefined => { if (id === CHEAP_HERO_TRAITOR) return { name: 'Cheap Hero / Heroine', factionName: 'Any faction', strength: 0, cheapHero: true, }; const known = g.allLeaders.find((l) => l.id === id); return known ? { id: known.id, name: known.name, factionName: faction(known.faction).name, strength: leaderStrengthLabel(known), } : undefined; }; const inspectIdentity = (id: string, kind: LeaderInspectorProps['kind']) => { const identity = identityFor(id); const assassination = kind === 'traitor' ? g.moritaniAssassinate?.history.find(entry => entry.card === id) : undefined; return identity ? ( ) : null; }; const actionButton = ( text: string, a: Action, disabled = false, describedBy?: string, ) => { const promises = liveShipmentPromises(g.shipmentPromises, me.id, g.turn); const shipmentBlocked = g.phase === 5 && shipmentAvailable && ['ship', 'guildShip', 'move', 'endMovement'].includes(a.type) && promises.some( (p) => matchesShipment( p, a.type === 'ship' || (a.type === 'guildShip' && a.from === 'reserves') ? { territory: String(a.territory), amount: Number(a.amount) } : null, ) !== p.answer, ); const atomicsShipmentBlocked = a.type === 'ship' && a.territory === g.moritaniAtomics?.territory; return ( ); }; const destination = ( <> ); const amountInput = ( <> {me.elites && ( )} ); const sourceInput = ( ); return (
Math.max(last, entry.seq), -1), automaticSequence: automaticEvents.reduce((last, entry) => Math.max(last, entry.seq), -1), }} /> {g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / ${g.victoryFinalTurn}`} Git revision: {buildRevisionLabel}
{g.status === 'lobby' ? 'WAR COUNCIL' : g.status === 'setup' ? 'PREPARING THE TABLE' : g.status === 'finished' ? 'THE FATE OF ARRAKIS' : PHASES[g.phase]?.toUpperCase()} {g.status === 'lobby' ? `${g.players.length} / 6 seats filled` : g.status === 'finished' ? g.winner .map( (id) => faction(g.players.find((p) => p.id === id)!.faction).name, ) .join(' + ') || 'No winner' : harkonnenBetrayalOwnsControls ? 'Declared Harkonnen traitor acknowledgement' : ixianBetrayalOwnsControls ? 'Native Ixian advantage acknowledgement' : g.guildBetrayalReaction ? 'Shipment payment acknowledgement' : g.richeseBetrayalReaction ? 'Richese auction acknowledgement' : g.kullReaction ? 'CHOAM Karama response' : g.semutaReaction ? 'Fresh discard response' : g.automaticContinuationPending ? 'Completing the card action' : g.truthtrance ? 'Truthtrance in progress' : g.phaseOpening ? 'Phase-opening decisions' : g.response ? 'Karama response in progress' : g.status === 'setup' ? setupStatus : turnOwnerName ? `${turnOwnerName} to act` : 'Simultaneous decisions'}
{g.advanced && g.status !== 'lobby' && } {g.homeworldOccupationPreview && ( Homeworld occupation development preview · printed bank Collection, percentage income, original Bidding effects, immediate ally sharing and occupied native defenses are connected. Advanced occupation benefits are retained until the occupier’s last force leaves; Basic lifecycle and unresolved competing-owner cases remain guarded. Not complete or certified Homeworld rules.{' '} Preview rules and limits )} {g.homeworldEcazVictoryPreview && ( Partial · Ecaz High Homeworld victory development preview, enabled only for this fresh preview game. Public progress is separate from ordinary stronghold and Ecaz three-joint-stronghold victory; it is evaluated only at the Mentat victory check. Unresolved source cases remain guarded. Not complete or certified Homeworld rules. )} {g.homeworldTupilePreview && ( Occupied Tupile development preview · the occupier and reciprocal ally each gain one extra hand slot. A player who loses it must discard eligible held cards to the normal limit.{' '} {g.advanced ? <>Advanced retains occupation benefits until the occupier’s last own force leaves; contests, native high population and turn changes do not end it. : <>Basic provisionally gives the slot to the current sole foreign controller. Departure, another foreign army or native high population ends it. If you regain control, finish your earlier required discards before using the extra slot again.}{' '} Continuous control never adds a second slot. No extra card or spice is granted. Not complete or certified Homeworld rules.{' '} Preview rules and limits )} {g.homeworldTupileBlocked && ( {g.homeworldTupileBlocked} )} {g.homeworldTupileCleanup?.blocked && (g.decision?.kind !== 'homeworldTupileCleanup' || g.decision.player !== g.me || g.decision.event !== g.homeworldTupileCleanup.event) && ( )} {g.homeworldOccupiedPercentage?.blocked && (g.decision?.kind !== 'homeworldOccupiedPercentage' || g.decision.player !== g.me) && ( )} {g.homeworldOccupiedBonus?.blocked && (g.decision?.kind !== 'homeworldOccupiedBonus' || g.decision.player !== g.me) && ( )} {g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked && !g.decision && ( {g.richeseBidding.offerBlocked} )} {g.semutaRecruitsPreview && (

Semuta Drug preview: in this independent-card game, Semuta can react to another player discarding Recruits, Hajr, Ghola, Harvester, Weather Control, Family Atomics, a completed Box search, Distrans, simple Truthtrance, ordinary Ornithopter or cards in supported battle cleanup. Other discard opportunities are still being implemented.{' '} Recruits rules

)} {g.semutaAdvancedOrdinaryPreview && (

Advanced Semuta preview: supported Hajr, Ghola, Harvester, Weather Control, completed Box, Distrans, simple Truthtrance, Residual Poison and CHOAM sale or Worthless discards can be claimed, along with supported battle cleanup. Other discard opportunities remain unfinished.

)} {g.semutaPreview && (

{g.homeworlds ? ( <> Semuta Drug with Homeworlds development preview · original Richese, CHOAM and classic occupation setup, with no other optional modules. Paid high-Kaitain closing discards, occupied Tupile lost-slot cleanup, paid Nullentropy Box search, clean Distrans transfer, definite Truthtrance answers and used ordinary special cards offer Semuta. Completed legal CHOAM market sales also offer their used card; recovery preserves the already credited two or three spice. Final Ornithopter disposal keeps the completed original groups; finite Once Around and clean unstarted movement Sapho costs keep the changed queue before its original next actor continues. Accepted losing-card, mandatory winning-card and selected winner disposal are separate fresh events. Recovery keeps the original casualties and paid native deployments; combat does not repeat. Clean Residual Poison keeps its one native random leader death without a bounty. Early Sapho aggressor and remaining Battle chooser costs keep their original participants and finite priority. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect. Printed Karama canceling these native powers or Guild, Emperor auction or Richese purchase income remains supported. Printed Karama canceling CHOAM opening income or Inflation and normal-lot purchase/payment costs keep the original denial, spent placement attempt or one zero-spice acquisition. Native Advanced BG extra-charity and allied Emperor gift/extra- revival cancellation costs also retain the consumed claim and original ally, casualties and unawarded benefit. Printed Karama canceling native capture, Auditor or quoted battle income also keeps accepted combat, paid support and original cleanup; the canceled reward does not occur. Private discarded faces stay hidden until commitment; a simultaneous group requires one selection. Claim or Continue resumes the same original source once without undoing payment or force commitment. Canceling native income does not cancel the printed occupied share: its original receiver still allocates it before Movement, the next auction lot or saved purchase controls. Other Homeworld producers and Basic unresolved Tupile loss remain guarded. Not complete or certified Homeworld rules.{' '} ) : ( <> Semuta Drug development preview · bounded fresh-discard reactions, not complete Richese or certified expansion rules. Fresh bare Basic Richese starts enable these reactions with their selected native families; older games are unchanged. Clean CHOAM and Ixian Ambassador discards, including BG copies, now pause after ordinary shipment or movement entry. CHOAM keeps its already-earned spice; Ixians draw their one replacement after the reaction. Other nested arrivals remain unfinished. Original Tech Tokens may remain in supported fresh 3–6-seat CHOAM/Ix profiles; the original winner’s award follows card cleanup and precedes Face Dance. Original Stronghold Cards may remain in Advanced 2–6-seat profiles and are claimed only at the end of Mentat. The explicit Leader Skills entry retains all fourteen original skills and native training. The explicit Nexus entry retains all twelve original Nexus cards and qualifying closing draws, never a starting Nexus hand. CHOAM Secret Ally trade, native CHOAM Betrayal and the five ordinary Cunning fuel discards can react before their original effects or receipts finish. Cunning fuel keeps its printed identity and private face; Kull retains its separate guard. A printed Karama canceling those native powers, the Emperor’s shared gift/revival, Guild shipment income, paid Emperor auction income or Richese special-purchase income also reacts before the original cancellation. The denied effect never applies; already-paid shipments and purchases are not undone. Taking Karama cannot restore the canceled power or refund a payment. Homeworlds, Discovery and other overlays stay guarded. Native Moritani support is Basic only: clean shipment/movement Sabotage and Robbery overflow plus native losing-ally card-retention cleanup; other arrival continuations remain guarded.{' '} )} Preview rules and limits

)} {g.nexusKullPreview ? (

CHOAM Nexus Cunning · Kull Wahad development preview. Any eligible Treachery Card can pay for the pending Karama interception; accepted declarations spend the Nexus even if a distinct Karama prevents Kull. This is not complete CHOAM or certified Nexus rules.{' '} Preview rules and limits

) : g.kullPreview && (

Kull Wahad development preview · provisional interception timing, not complete CHOAM or certified expansion rules.{' '} {g.advanced && 'Advanced Kull prevents the attempted specific Karama effect; its phase duration is provisional. '} Preview rules and limits

)} {g.richeseBetrayalPreview && (

Richese Nexus Betrayal development preview · public cache self-purchase veto and ordinary other-buyer sale diversion only; not complete Richese or certified Nexus rules.{' '} Preview rules and limits

)} {coreNexusPreview && (

Nexus core reactions preview · Basic/Advanced classic factions with base33 or independent36, or selected expansion decks with the independent three-card variant, permitting registered classic and selected native seats. Optional all14 Leader Skills and Homeworlds compose. Own-World Reinforcements fees provisionally spend ordinary non-role counters first, reducing temporary Sardaukar roles only when converted counters pay. Original Tech (3+ seats) and Advanced Strongholds (2+ seats) may compose. Native reactions retain original card, force and payment authority. Acknowledgement timing and purchase/bonus ordering remain provisional. Other modules and complete Nexus acceptance remain separate.{' '} Preview rules and limits

)} {g.guildBetrayalPreview && !coreNexusPreview && (

Guild Nexus Betrayal development preview · whole funded shipment payment replacement, including your own payment and occupied Junction. Classic factions with native Guild and Nexus; optional genuinely seeded Homeworlds only. Not complete Guild or certified Nexus rules.{' '} Preview rules and limits

)} {g.nexusIxianReplacementPreview && !coreNexusPreview && (

Ixian Nexus purchased-card replacement development preview · original supported standalone Tleilaxu or Richese entries. Normal paid/Karama and supported native Richese purchases retain their exact source; replacement-before-bonus and cache interpretations remain provisional. Not complete Ixian or certified Nexus rules.{' '} Preview rules and limits

)} {g.nexusIxianBetrayalPreview && !coreNexusPreview && (

Ixian Nexus Betrayal development preview · native Ixian Bidding extra-card inspection/draw and Advanced Technology exchange prevention. Native Ixians, permitted classic/Tleilaxu seats, physical Ixian Treachery and Nexus only. Native Karama responses finish first. Not complete Ixian or certified Nexus rules.{' '} Preview rules and limits

)} {g.nexusHarkonnenBetrayalPreview && !coreNexusPreview && (

Harkonnen Nexus Betrayal development preview · cancel an actually declared native Harkonnen traitor call, return that physical card to the Traitor Deck, and draw one private replacement automatically at this turn’s Mentat Pause. Classic Basic/Advanced, native Harkonnen, base Treachery and Nexus only. Native allied Karama counters finish first. Not complete Harkonnen or certified Nexus rules.{' '} Preview rules and limits

)} {g.status !== 'lobby' && ( )} {g.sandtrout && (

Sandtrout is waiting: the next Shai-Hulud card will be suppressed. An immediate territory replacement receives double spice.

)} {g.spicePeek && (
Private foresight · Atreides

{spiceCardTitle(g.spicePeek)}

Only you can see this upcoming card.

)} {g.arrivalOverlap && (

{g.players.find((player) => player.id === g.arrivalOverlap!.entrant)?.name ?? 'The entrant'} {' '}entered {territory(g.arrivalOverlap.territory).name}. Ecaz Ambassador and Moritani Terror resolve in this table’s provisional storm order:{' '} {g.arrivalOverlap.first === 'ambassador' ? 'Ecaz, then Moritani' : 'Moritani, then Ecaz'}. {g.semutaReaction ? ' The current fresh-discard response must finish before the next arrival decision.' : <> {g.arrivalOverlap.active === 'ambassador' ? 'Ecaz' : 'Moritani'} decides now.} {' '}The original entry is not repeated.

)}
Storm · Sector {g.storm}
{PLAYER_CIRCLE_SECTORS.map((circleSector, index) => { const angle = ((110 - (circleSector - 1) * 20) * Math.PI) / 180; const player = g.players.find( (p) => g.playerPositions[p.id] === index + 1, ); return ( {`Player circle ${index + 1}, sector ${circleSector}: ${player ? `${player.name} · ${faction(player.faction).name}` : 'Empty'}`} {index + 1} ); })} {g.mobileStronghold?.location && ( )} {gameTerritories(g).map((t) => ( choose(t.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); choose(t.id); } }} > p.join(',')).join(' ')} /> {t.name} ))} {Array.from({ length: 18 }, (_, i) => { const a = (Math.PI / 180) * (120 - i * 20); return ( {i + 1} ); })} {gameTerritories(g).flatMap((t) => { const present = g.players.filter((p) => Object.keys(p.forces).some( (k) => splitLocation(k).territory === t.id && p.forces[k], ), ); return present.map((p, i) => ( {isAdvisor(p, t.id) && ( )} {Object.entries(p.forces) .filter(([k]) => splitLocation(k).territory === t.id) .reduce((n, [, v]) => n + v, 0)} )); })} {Object.entries(g.spice) .filter(([, n]) => n > 0) .map(([k, n]) => { const t = territory(splitLocation(k).territory); return ( ◆ {n} ); })}
{territory(selected).type.toUpperCase()}

{territory(selected).name}

{selected === MOBILE_STRONGHOLD && g.mobileStronghold?.location ? `Points at ${territory(splitLocation(g.mobileStronghold.location).territory).name}, sector ${splitLocation(g.mobileStronghold.location).sector}` : `Sectors ${territory(selected).sectors.join(' · ')}`}
{g.players.flatMap((p) => Object.entries(p.forces) .filter(([k]) => splitLocation(k).territory === selected) .map(([k, n]) => ( {faction(p.faction).name}: {n}{' '} {isAdvisor(p, selected) ? 'advisors' : 'forces'} in sector{' '} {splitLocation(k).sector} )), )} {g.players.flatMap((p) => { const marker = p.noField?.deployed; if (!marker || !isBoardNoFieldLocation(marker.location) || marker.location.territory !== selected) return []; return [ {faction(p.faction).name}: concealed No-Field in sector{' '} {marker.location.sector} · counts as one force ]; })}
{g.status !== 'lobby' && (
{g.playedOrnithopter && (

Ornithopter in play

{ g.players.find( (p) => p.id === g.playedOrnithopter!.player, )?.name }{' '} ·{' '} {g.playedOrnithopter.mode === 'range3' ? 'Three-territory group' : 'Two different groups'}{' '} · {g.playedOrnithopter.completed} completed

)}

Spice cards on the table

{g.spiceDiscardTop .slice(0, g.advanced ? 2 : 1) .map((card, index) => (
{g.advanced ? `Spice pile ${index + 1}` : 'Spice pile'}{' '} · top card {card ? ( <> {spiceCardTitle(card)} ) : (

No face-up card yet.

)}
))}
)}
{g.inflation && (

Inflation · {g.inflation.side === 'double' ? 'DOUBLE' : 'CANCEL'} ·{' '} {g.phase === 8 && g.inflation.updatedTurn === g.turn ? 'Next turn’s charity' : 'This turn’s charity'} {g.inflation.side === 'double' ? ' · Bribes prohibited' : ''} {g.inflation.flipped ? ' · Removed at the next Mentat Pause' : ' · Flips at the next Mentat Pause'}

)} { void send(action); }} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} /> {!reactionOwnsControls && }
{panel === 'hand' && reactionOwnsControls ? ( Your Treachery hand is empty.

}> {(card) => (

{card.name}

)}
) : panel === 'hand' ? ( <> {setupStage ? g.leaderSkills ? 'Starting Treachery Cards are dealt after prediction and before skill assignment.' : 'Your starting Treachery cards remain undealt until all starting force placement is complete.' : g.status === 'lobby' ? 'Your opening cards are dealt after starting force placement.' : 'Your Treachery hand is empty.'}

}> {(c) => { const richese = richeseCardDefinition(c); const presentation = cardPresentation(c); const ecaz = ecazTreacheryDefinition(c); const block = (ecaz ? ecaz.card.effect === 'recruits' ? g.recruitsPreview?.play?.blocked ?? 'Use the Recruits panel during Revival to play this card.' : ecaz.card.effect === 'harassWithdraw' ? g.battle?.harassWithdraw?.blocked ?? 'Use either battle-card slot to play Harass & Withdraw.' : g.battle?.reinforcements?.blocked ?? 'Use either battle-card slot to play Reinforcements.' : null) ?? (richese?.card.effect === 'distrans' ? 'Use the Distrans transfer panel above to choose a recipient and another card.' : richese?.card.effect === 'nullentropyBox' ? 'Use the Nullentropy Box panel above to begin the paid search.' : richeseCardActionBlock(c)) ?? (canUseAsKaramaRole(g, me, c) ? g.karamaBlocked ? g.karamaBlocked : g.response ? g.nexusTraitors?.pending ? 'Finish the private Nexus Traitor card return before responding to this power.' : !g.responseControls?.cancelCards.includes(c.id) ? 'This card cannot cancel the current power.' : null : g.decision ? 'Finish the pending decision before taking an auction card.' : kullPurposeBlock(g, 'auction-free-purchase') ? kullPurposeBlock(g, 'auction-free-purchase') : g.phase !== 3 || !g.auction || g.richeseAuction ? 'The Karama purchase option needs an ordinary auction in progress.' : (me.hand?.length ?? 0) >= me.handLimit ? 'Your hand must have room before a Karama auction purchase.' : null : null); const gholaBlock = c.effect !== 'ghola' ? null : !g.ghola.available ? g.ghola.reason : !g.ghola.cards.includes(c.id) ? 'This card is reserved for the Black Market auction.' : leader ? ( leader === 'kwisatz' ? g.ghola.kwisatz : g.ghola.leaders.some((l) => l.id === leader) ) ? null : 'Choose a currently eligible leader.' : !Number.isSafeInteger(amount) || amount < 1 || amount > g.ghola.maxForces ? `Choose between 1 and ${g.ghola.maxForces} eligible forces.` : !Number.isSafeInteger(eliteAmount) || eliteAmount < Math.max( 0, amount - (me.tanks - (me.elites?.tanks ?? 0)), ) || eliteAmount > Math.min(amount, g.ghola.eliteRemaining) ? 'Choose an eligible number of elite forces.' : null; const availability = block || gholaBlock ? { available: false as const, reason: block || gholaBlock!, } : ordinaryCardAvailability( g, c.id, c.effect === 'weather' ? { amount: weatherDistance } : undefined, ); return (
{richese ? presentation.category : battleCardLabel(c.kind)}

{c.name}

{availability && !availability.available && (

{availability.reason}

)} {!availability && g.status === 'setup' && !g.response && c.effect !== 'truthtrance' && (

Finish setup before using this card.

)} {richese ? presentation.role === 'weapon' ? '†' : presentation.role === 'defense' ? '◇' : '✧' : c.kind === 'special' ? '✧' : c.kind === 'shield' || c.kind === 'snooper' ? '◇' : '†'} {canUseAsTruthtranceRole(g, me, c) && ( <>

{c.effect === 'karama' ? 'Shrine lets you use this physical Karama as Truthtrance while you occupy it. ' : ''} Ask another player a public yes/no question, including during another player’s decision. Competing plays follow storm order.

{(me.hand?.filter( (other) => canUseAsTruthtranceRole(g, me, other), ).length ?? 0) > 1 && ( )} )} {c.effect === 'amal' && (

At a phase opening, every faction returns half its available spice to the bank, rounded up. Play before everyone passes.

)} {c.effect === 'thumper' && (

Play before the first spice draw to resolve Shai-Hulud without drawing a card. On later turns, Fremen protection, rides, Nexus and a waiting Sandtrout apply.{' '} {g.turn === 1 ? <>First-turn preview policy: spending Thumper discards it, but its worm encounter is ignored. Normal spice drawing continues. : g.sandtrout ? <> A waiting Sandtrout will suppress this encounter. Only an immediate territory replacement receives double spice; another worm instead resolves normally. : null}

)} {c.effect === 'ghola' && ( <> {g.ghola.eliteBlock && (

{g.ghola.eliteBlock}

)} {homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined }) && (

{homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined })}

)} {me.elites && ( )} )} {g.advanced && canActivateKarama(c) && !me.specialKaramaUsed && me.faction === 'harkonnen' && g.phase === 3 && ( <>

Once per game: take up to four unseen cards, inspect them, then return the same number.

{actionButton( 'Use special Karama · exchange', { type: 'card', mode: 'special', card: c.id, target, amount, }, !target, )} )} {g.advanced && canActivateKarama(c) && !me.specialKaramaUsed && me.faction === 'fremen' && g.phase === 1 && ( <>

Once per game: summon Shai-Hulud in a sand territory. Resolve protection now; Nexus and rides follow the spice blow.

{actionButton( 'Spend Karama · summon Shai-Hulud', { type: 'card', mode: 'special', card: c.id, territory: selected, }, !TERRITORIES.some( (t) => t.id === selected && t.type === 'sand', ), )} )} {g.advanced && canActivateKarama(c) && !me.specialKaramaUsed && me.faction === 'emperor' && g.phase === 4 && ( <>

Once per game: revive up to three forces or one leader for free. These returns do not use your ordinary revival allowances, including the normal one-Sardaukar limit.

{g.revival.sardaukarFreeBlock &&

{g.revival.sardaukarFreeBlock}

} {me.elites && ( )} {actionButton( 'Use special Karama · forces', { type: 'card', mode: 'special', card: c.id, amount, elite: eliteAmount, }, !Number.isSafeInteger(amount) || amount < 1 || amount > Math.min(3, me.tanks - (g.revival.sardaukarFreeBlock ? (me.elites?.tanks ?? 0) : 0)) || !Number.isSafeInteger(eliteAmount) || eliteAmount < Math.max(0, amount - (me.tanks - (me.elites?.tanks ?? 0))) || eliteAmount > (g.revival.sardaukarFreeBlock ? 0 : Math.min(amount, me.elites?.tanks ?? 0)), )} {me.leaders .filter( (l) => l.dead && !l.capturedBy && !l.gholaBy, ) .map((l) => (
{actionButton( `Revive ${l.name} with special Karama`, { type: 'card', mode: 'special', card: c.id, leader: l.id, }, )}
))} )} {c.effect === 'weather' && ( )} {canSpendKarama(c, 'shipment-price') && g.phase === 5 && g.active && (g.active === me.id ? shipmentAvailable : !g.players.find((p) => p.id === g.active)?.shipped) && ( )} {(c.effect !== 'truthtrance' || canUseAsKaramaRole(g, me, c)) && (c.kind === 'special' || canUseAsKaramaRole(g, me, c)) && ( )}
); }}
{!!me.faceDancers?.length && (

Your Face Dancers

{me.faceDancers.map((c) => (
{leaderName(c.leader)} ·{' '} {c.revealed ? 'Revealed' : 'Secret'} {inspectIdentity(c.leader, 'faceDancer')} {g.phase === 8 && !c.revealed && !me.faceDancerReplaced && actionButton( 'Replace this Face Dancer', { type: 'replaceFaceDancer', leader: c.leader }, !!g.phaseOpening || !!g.response || !!g.decision, )}
))}
)} {!!me.traitors?.length && (
YOUR TRAITORS {me.traitors.map((id) => (
{leaderName(id)} {inspectIdentity(id, 'traitor')}
))}
)} ) : panel === 'leaders' ? ( <>

Inspect your leader identities here. Choose and commit a leader through the battle controls when making a plan.

{me.leaders.map((l) => (
Leader strength {leaderStrengthLabel(l)} } fallback={
Leader strength {leaderStrengthLabel(l)}
} />

{faction(l.faction).name}

{l.name}

{l.dead ? 'In the tanks' : !controlsLeader(me, l) ? 'Controlled by another faction' : l.usedAt ? 'Used in a battle this turn' : 'Ready'}

))}
) : panel === 'log' ? (
    {[...g.log].reverse().map((e) => (
  1. {String(e.seq).padStart(3, '0')}

    {e.text.replace( /used Karama to cancel fremenMovement\.$/, 'used Karama to cancel Fremen movement.', )}

  2. ))}
) : (

Rules verification in progress

This build implements a playable portion of the basic game. It is not yet a fully rules-compliant edition.

Still in progress: expansion factions and modules, advanced rules, several special cards, optional faction reactions, ally support, special-auction timing, and a printed-board connectivity audit.

Open the rules coverage ledger
)}
{g.strongholdCards && (
Inspect Stronghold Cards
)}
); }