'use client'; import { integerInputValue } from '../lib/integer-input'; 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 { 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 { matchesShipment, liveShipmentPromises, } from '@/game/shipment-promises'; import { ShipmentPromises } from './shipment-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 { 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 { 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 { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options'; import { atomicsAllianceChangeBlocked } from '@/game/moritani-atomics'; import { GuildHomeworldShipment } from './guild-homeworld-shipment'; import { JunctionTransport } from './junction-transport'; import { BiddingEnd } from './bidding-end'; import { homeworldShipmentPaymentBlock } from '@/game/homeworld-payment-options'; 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 { 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 { LeaderSkillsPanel } from './leader-skills'; import { LeaderSkillBattleGuide } from './leader-skill-battle-guide'; import { smugglerBattleModeSupported, smugglerBattlePlanBlock } from '@/game/smuggler-battle'; import { RihaniChoice, RihaniHistory } from './rihani-decipherer'; import { MentatQuestion, MentatHistory } from './mentat-question'; import { BureaucratPayment } from './bureaucrat-payment'; import { Bribes } from './bribes'; 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 { SandmasterMovement } from './sandmaster-movement'; import { SandmasterWormChoice } from './sandmaster-worm'; import { sandmasterWormCollection } from '@/game/sandmaster-worm'; 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 { 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 { forceRevivalPrice, eliteRevivalRemaining, paidForceRevivalCost, } from '@/game/revival'; import { IxTechnology } from './ix-technology'; import { MobileStronghold } from './mobile-stronghold'; import { IxSubstitution } from './ix-substitution'; import { controlsLeader } from '@/game/leader-control'; import { leaderStrengthLabel, harvesterAvailable } from '@/game/cards'; import { RevivalCommerce } from './revival-commerce'; import { FaceDanceDecision } from './face-dance-decision'; import { CHEAP_HERO_TRAITOR, matchingTraitor } from '@/game/traitors'; import { battleCardLabel, isStoneBurner, isMirrorWeapon, } from '@/game/battle-cards'; import { validBattleSlotPair } from '@/game/battle-card-slots'; import { stoneBurnerPlanBlock } from '@/game/stone-burner'; import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode'; import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens'; import { canUseAsKaramaRole, canUseAsTruthtranceRole, } from '@/game/shrine'; import { LeaderPortrait } from './leader-portrait'; import { ShipmentQuote } from './shipment-quote'; import { 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 } from '@/game/bot-mobility'; import { isAlliedShipmentVisit } from '@/game/allied-separation'; import { location as boardLocation } from '@/game/board'; 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 } 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 } from '@/game/engine'; 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'; 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 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 combatName = (id: string) => g.combatLocations?.find((place) => place.id === id)?.name ?? gameTerritories(g).find((place) => place.id === id)?.name ?? id; const turnOwner = 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.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.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; const canActivateKarama = (card: Card) => !g.karamaBlocked && canUseAsKaramaRole(g, me, card); const traitorBattle = g.battle; const 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?.traitorVoters.includes(me.id) && traitorPlan && !traitorPlan.kwisatz && matchingTraitor( me.traitors ?? [], traitorPlan.leader, traitorBattle.cards.find((card) => card.id === traitorPlan.leader), ) ); const noTraitorReason = 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' || 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 maximumBid = me.hand?.some((c) => canUseAsKaramaRole(g, me, c), ) ? Number.MAX_SAFE_INTEGER : (me.spice ?? 0) + g.aid.available; 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 [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 ownBattleForces = g.battle?.ownForces; 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 || 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 [combineSectors, setCombineSectors] = useState(false); const [moveNoField, setMoveNoField] = useState(false); const [moveForces, setMoveForces] = useState>({}); const [rideForces, setRideForces] = useState>({}); const [collectSandmasterRide, setCollectSandmasterRide] = useState(true); const sandmasterRide = sandmasterWormCollection(g, me.id, selected, sector); const [exchangeCards, setExchangeCards] = useState>( {}, ); const [discardCards, setDiscardCards] = useState>({}); const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null; const committed = battlePlanCommitments(g); const selectedBattleLeader = committed.leader ? String(committed.leader.value ?? '') : leader; 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 planetologistSpecial = (card: Card) => canUsePlanetologistBattleSpecial({ assignments: g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [], selectedLeader: committed.leader ? String(committed.leader.value ?? '') : leader, card, }); 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 reinforcementsSelected = selectedWeaponId === 'ecaz-reinforcements' || selectedDefenseId === 'ecaz-reinforcements'; const reinforcementsPlanReason = !reinforcementsSelected ? null : !g.battle?.reinforcements ? 'Reinforcements is unavailable in this battle.' : g.battle.reinforcements.blocked ?? ((selectedBattleWeapon?.id === 'ecaz-harass-withdraw' || selectedBattleDefense?.id === 'ecaz-harass-withdraw' || (!!selectedBattleWeapon && isStoneBurner(selectedBattleWeapon))) ? 'Reinforcements cannot share this plan with Harass & Withdraw or Stone Burner.' : 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, }, smugglerBattleModeSupported(g), { 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, !!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon)); const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw, selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw', 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 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', stoneContext.occupy, ), ) .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 shipmentRate = { faction: me.faction, halfRate: g.karamaShipping?.player === me.id || (!g.guildRateCanceled && (me.faction === 'guild' || 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 selectedAllyPayment = shipmentCost === null ? 0 : !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) { const blocked = homeworldShipmentPaymentBlock(g, shipmentCost, selectedAllyPayment); if (blocked) shipmentProblems.push(blocked); } if (!validShipmentShare) shipmentProblems.push( `Ally payment must be a whole number from 0 to ${shipmentCost}.`, ); else { if (selectedAllyPayment > g.aid.available) shipmentProblems.push( `Only ${g.aid.available} 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 ownNoField = g.richeseNoField?.owner === me.id ? g.richeseNoField.private?.deployed : 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 transportAction: Action = { type: 'guildShip', ...(useGuildSecret ? {nexus: guildSecretEvent} : {}), ...payment, ...movementGroup, territory: selected, sector, amount, }; const southernTransportAction: Action = { type: 'guildShip', from: 'reserves', elite: eliteAmount, territory: selected, sector, amount, ...payment, }; const returnTransportAction: Action = { type: 'guildShip', ...(useGuildSecret ? {nexus: guildSecretEvent} : {}), ...payment, ...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 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} / 10`} 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 server-derived hand slot; losing it requires the original owner to discard eligible held cards to the normal limit. Advanced rulebook page 22 retains occupation benefits until the occupier’s last own force leaves, not on contests, native high population or turn changes; a new sole occupier after departure starts a new epoch. Basic unresolved expiry and competing-owner cases remain guarded. 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.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.{' '} 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

)} {g.guildBetrayalPreview && (

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 && (

Ixian Nexus purchased-card replacement development preview · classic Basic/Advanced with base Treachery, or Basic Tleilaxu/classic with Ixian Treachery; Nexus alone. Ixians are absent. Normal paid and printed Karama purchases are supported; Harkonnen buyers and special auctions are guarded. Not complete Ixian or certified Nexus rules.{' '} Preview rules and limits

)} {g.nexusIxianBetrayalPreview && (

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 && (

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 storm order:{' '} {g.arrivalOverlap.first === 'ambassador' ? 'Ecaz, then Moritani' : 'Moritani, then Ecaz'}. {' '}{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 .filter( (p) => p.noField?.deployed?.location.territory === selected, ) .map((p) => ( {faction(p.faction).name}: concealed No-Field in sector{' '} {p.noField!.deployed!.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'}

)} {!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.' : 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. Fremen protection, rides, Nexus and a waiting Sandtrout apply.

)} {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.

{me.elites && ( )} {actionButton( 'Use special Karama · forces', { type: 'card', mode: 'special', card: c.id, amount, elite: eliteAmount, }, !me.tanks, )} {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' && ( )} {canActivateKarama(c) && 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, auction deadlines, and a printed-board connectivity audit.

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