6709 lines
343 KiB
TypeScript
6709 lines
343 KiB
TypeScript
'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 { 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<void>;
|
||
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<Difficulty>('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<Record<string, number>>({});
|
||
const [eliteSetup, setEliteSetup] = useState<Record<string, number>>({});
|
||
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 [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<Record<string, number>>({
|
||
[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);
|
||
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<Record<string, number>>({});
|
||
const [rideForces, setRideForces] = useState<Record<string, number>>({});
|
||
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<Record<string, boolean>>(
|
||
{},
|
||
);
|
||
const [discardCards, setDiscardCards] = useState<Record<string, boolean>>({});
|
||
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 ? (
|
||
<LeaderInspector kind={kind} identity={identity}
|
||
assassination={assassination ? assassination.stage === 'replaced' ? 'retired' : 'revealed' : undefined} />
|
||
) : 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 (
|
||
<Button
|
||
className="game-action"
|
||
aria-describedby={
|
||
[
|
||
describedBy,
|
||
shipmentBlocked ? 'shipment-promise-guidance' : undefined,
|
||
atomicsShipmentBlocked ? 'atomics-shipment-guidance' : undefined,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ') || undefined
|
||
}
|
||
disabled={busy || !!g.truthtrance || !!g.nexusTraitors?.pending || disabled || shipmentBlocked || atomicsShipmentBlocked}
|
||
onClick={() => act(a)}
|
||
>
|
||
{text}
|
||
<ArrowRight size={15} />
|
||
</Button>
|
||
);
|
||
};
|
||
const destination = (
|
||
<>
|
||
<label>
|
||
Destination
|
||
<select value={selected} onChange={(e) => choose(e.target.value)}>
|
||
{gameTerritories(g).map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label htmlFor="destination-sector">
|
||
Sector <HelpTip topic="sector" />
|
||
<select
|
||
id="destination-sector"
|
||
value={sector}
|
||
onChange={(e) => setSector(Number(e.target.value))}
|
||
>
|
||
{territory(selected).sectors.map((s) => (
|
||
<option key={s} value={s}>
|
||
{s === 0
|
||
? selected === MOBILE_STRONGHOLD
|
||
? 'Inside stronghold'
|
||
: selected === 'polar_sink'
|
||
? 'Polar sink'
|
||
: 'Inside location · sector 0'
|
||
: s}
|
||
{s === g.storm ? ' · Storm' : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</>
|
||
);
|
||
const amountInput = (
|
||
<>
|
||
<label htmlFor="forces">
|
||
{includesNoField ? 'Physical forces accompanying No-Field' : 'Forces'}
|
||
<Input
|
||
id="forces"
|
||
type="number"
|
||
min={includesNoField ? 0 : 1}
|
||
max={useRicheseShipment && !me.shipped ? shipmentMaximum : 20}
|
||
value={amount}
|
||
onChange={(e) => setAmount(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
{me.elites && (
|
||
<EliteCount
|
||
value={eliteAmount}
|
||
onChange={setEliteAmount}
|
||
max={Math.min(amount, me.faction === 'ixians' ? 7 : 5)}
|
||
label={me.faction === 'ixians' ? 'Cyborgs' : 'Elite forces'}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
const sourceInput = (
|
||
<label>
|
||
From
|
||
<select
|
||
value={source}
|
||
onChange={(e) => {
|
||
const next = e.target.value;
|
||
setSource(next);
|
||
const markerOnly =
|
||
!!ownNoField &&
|
||
`${ownNoField.location.territory}:${ownNoField.location.sector}` ===
|
||
next &&
|
||
!(me.forces[next] > 0);
|
||
setMoveNoField(markerOnly);
|
||
if (markerOnly) setAmount(0);
|
||
else if (amount === 0) setAmount(1);
|
||
}}
|
||
>
|
||
<option value="">Select your forces or No-Field</option>
|
||
{Object.entries(presenceByLocation(me))
|
||
.filter(([, n]) => n > 0)
|
||
.map(([k, n]) => (
|
||
<option key={k} value={k}>
|
||
{territory(splitLocation(k).territory).name} ·{' '}
|
||
{splitLocation(k).sector} ({me.forces[k] ?? 0} physical forces
|
||
{n > (me.forces[k] ?? 0) ? ' + concealed No-Field' : ''})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
);
|
||
return (
|
||
<main className="table-shell live-table">
|
||
<header className="masthead">
|
||
<button
|
||
className="wordmark wordmark-button"
|
||
onClick={onExit}
|
||
disabled={transportBusy || exitDisabled}
|
||
aria-label="Return to lobby"
|
||
>
|
||
DUNE<span>ARRAKIS TABLE</span>
|
||
</button>
|
||
<div className="masthead-right">
|
||
<AutomaticActionNotice key={g.code} events={automaticEvents} />
|
||
<TableSounds key={`sound:${g.code}`} frame={{
|
||
room: g.code,
|
||
phase: `${g.status}:${g.turn}:${g.phase}`,
|
||
sequence: g.log.reduce((last, entry) => Math.max(last, entry.seq), -1),
|
||
automaticSequence: automaticEvents.reduce((last, entry) => Math.max(last, entry.seq), -1),
|
||
}} />
|
||
<span className="status-dot" />
|
||
{g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / ${g.victoryFinalTurn}`}
|
||
<span className="build-revision" title={buildRevision}>
|
||
Git revision: <code>{buildRevisionLabel}</code>
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
onClick={async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(
|
||
`${window.location.origin}/?room=${g.code}`,
|
||
);
|
||
setCopied(true);
|
||
} catch {
|
||
setCopied(false);
|
||
}
|
||
}}
|
||
>
|
||
{copied ? <Check size={15} /> : <Copy size={15} />} {g.code}
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
<RoomControlNotice control={g.roomControl} />
|
||
<div className="game-banner">
|
||
<span className="eyebrow">
|
||
{g.status === 'lobby'
|
||
? 'WAR COUNCIL'
|
||
: g.status === 'setup'
|
||
? 'PREPARING THE TABLE'
|
||
: g.status === 'finished'
|
||
? 'THE FATE OF ARRAKIS'
|
||
: PHASES[g.phase]?.toUpperCase()}
|
||
</span>
|
||
<span>
|
||
{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'}
|
||
</span>
|
||
</div>
|
||
{g.advanced && g.status !== 'lobby' && <AdvancedPreviewNotice compact />}
|
||
{g.homeworldOccupationPreview && (
|
||
<output className="notice block">
|
||
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.{' '}
|
||
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
|
||
</output>
|
||
)}
|
||
{g.homeworldEcazVictoryPreview && (
|
||
<output className="notice block">
|
||
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.
|
||
</output>
|
||
)}
|
||
{g.homeworldTupilePreview && (
|
||
<output className="notice block">
|
||
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.{' '}
|
||
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
|
||
</output>
|
||
)}
|
||
{g.homeworldTupileBlocked && (
|
||
<output className="notice block">{g.homeworldTupileBlocked}</output>
|
||
)}
|
||
{g.homeworldTupileCleanup?.blocked && (g.decision?.kind !== 'homeworldTupileCleanup' || g.decision.player !== g.me || g.decision.event !== g.homeworldTupileCleanup.event) && (
|
||
<HomeworldTupileCleanup game={g} act={act} busy={true} />
|
||
)}
|
||
{g.homeworldOccupiedPercentage?.blocked && (g.decision?.kind !== 'homeworldOccupiedPercentage' || g.decision.player !== g.me) && (
|
||
<HomeworldOccupiedPercentage game={g} act={act} busy={true} />
|
||
)}
|
||
{g.homeworldOccupiedBonus?.blocked && (g.decision?.kind !== 'homeworldOccupiedBonus' || g.decision.player !== g.me) && (
|
||
<HomeworldOccupiedBonus game={g} act={act} busy={true} />
|
||
)}
|
||
{g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked && !g.decision && (
|
||
<output className="notice block" aria-label="Blocked Richese cache choice">
|
||
{g.richeseBidding.offerBlocked}
|
||
</output>
|
||
)}
|
||
{g.semutaRecruitsPreview && (
|
||
<p className="notice" role="status">
|
||
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.{' '}
|
||
<a href="/rules?topic=card-recruits#card-recruits">Recruits rules</a>
|
||
</p>
|
||
)}
|
||
{g.semutaPreview && (
|
||
<p className="notice" role="status">
|
||
{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.{' '}
|
||
</>
|
||
)}
|
||
<a href="/rules?topic=card-richese-semuta-drug#card-richese-semuta-drug">Preview rules and limits</a>
|
||
</p>
|
||
)}
|
||
{g.nexusKullPreview ? (
|
||
<p className="notice" role="status">
|
||
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.{' '}
|
||
<a href="/rules?topic=nexus-choam-kull#nexus-choam-kull">Preview rules and limits</a>
|
||
</p>
|
||
) : g.kullPreview && (
|
||
<p className="notice" role="status">
|
||
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. '}
|
||
<a href="/rules?topic=choam-kull#choam-kull">Preview rules and limits</a>
|
||
</p>
|
||
)}
|
||
{g.richeseBetrayalPreview && (
|
||
<p className="notice" role="status">
|
||
Richese Nexus Betrayal development preview · public cache self-purchase
|
||
veto and ordinary other-buyer sale diversion only; not complete Richese
|
||
or certified Nexus rules.{' '}
|
||
<a href="/rules?topic=nexus-richese-betrayal#nexus-richese-betrayal">Preview rules and limits</a>
|
||
</p>
|
||
)}
|
||
{coreNexusPreview && (
|
||
<p className="notice" role="status">
|
||
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.{' '}
|
||
<a href="/rules?topic=nexus-cards#nexus-cards">Preview rules and limits</a>
|
||
</p>
|
||
)}
|
||
{g.guildBetrayalPreview && !coreNexusPreview && (
|
||
<p className="notice" role="status">
|
||
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.{' '}
|
||
<a href="/rules?topic=nexus-guild-betrayal#nexus-guild-betrayal">Preview rules and limits</a>
|
||
</p>
|
||
)}
|
||
{g.nexusIxianReplacementPreview && !coreNexusPreview && (
|
||
<p className="notice" role="status">
|
||
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.{' '}
|
||
<a href="/rules?topic=nexus-ixian-replacement#nexus-ixian-replacement">Preview rules and limits</a>
|
||
</p>
|
||
)}
|
||
{g.nexusIxianBetrayalPreview && !coreNexusPreview && (
|
||
<p className="notice" role="status">
|
||
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.{' '}
|
||
<a href="/rules?topic=nexus-ixian-betrayal#nexus-ixian-betrayal">Preview rules and limits</a>
|
||
</p>
|
||
)}
|
||
{g.nexusHarkonnenBetrayalPreview && !coreNexusPreview && (
|
||
<p className="notice" role="status">
|
||
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.{' '}
|
||
<a href="/rules?topic=nexus-harkonnen-betrayal#nexus-harkonnen-betrayal">Preview rules and limits</a>
|
||
</p>
|
||
)}
|
||
{g.status !== 'lobby' && (
|
||
<nav className="phase-track" aria-label="Turn phases">
|
||
{PHASES.map((p, i) => (
|
||
<span
|
||
key={p}
|
||
className={i === g.phase ? 'current' : i < g.phase ? 'done' : ''}
|
||
>
|
||
<b>{i + 1}</b>
|
||
{p}
|
||
</span>
|
||
))}
|
||
</nav>
|
||
)}
|
||
{g.sandtrout && (
|
||
<p className="notice">
|
||
Sandtrout is waiting: the next Shai-Hulud card will be suppressed. An
|
||
immediate territory replacement receives double spice.
|
||
</p>
|
||
)}
|
||
{g.spicePeek && (
|
||
<section
|
||
className="private-spice-preview"
|
||
aria-label="Your private spice foresight"
|
||
>
|
||
<div>
|
||
<span className="eyebrow">Private foresight · Atreides</span>
|
||
<h3>{spiceCardTitle(g.spicePeek)}</h3>
|
||
<p className="fine">Only you can see this upcoming card.</p>
|
||
</div>
|
||
<SpiceCardInspector card={g.spicePeek} context="Private foresight" />
|
||
</section>
|
||
)}
|
||
<SeatAutopilot game={g} act={act} busy={transportBusy || !!g.factionDraft || !!g.factionSwap} />
|
||
<DukeVidal game={g} />
|
||
<EcazLoyaltyCard loyalty={g.ecazLoyalty} />
|
||
<MoritaniTerrorSupply game={g} />
|
||
<MoritaniExtortionNotice game={g} />
|
||
{g.arrivalOverlap && (
|
||
<section className="notice" aria-label="Ambassador and Terror response order">
|
||
<p>
|
||
{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.
|
||
</p>
|
||
</section>
|
||
)}
|
||
<MoritaniAssassinateHistory game={g} />
|
||
<AmbassadorSupply game={g} />
|
||
<AmbassadorInsights key={`${g.code}-${g.me}`} game={g} />
|
||
<AuditorInsight game={g} />
|
||
<NexusChoamInspectionInsight game={g} />
|
||
<div className="play-grid">
|
||
<aside className="players-panel">
|
||
<div className="eyebrow">THE GREAT HOUSES</div>
|
||
{g.players.map((p) => (
|
||
<div
|
||
key={p.id}
|
||
className={`player-card ${p.id === turnOwner ? 'active-player' : ''}`}
|
||
style={
|
||
{
|
||
'--faction-color': faction(p.faction).color,
|
||
} as React.CSSProperties
|
||
}
|
||
>
|
||
<div className="player-heading">
|
||
<span className="sigil">{faction(p.faction).sigil}</span>
|
||
<span>
|
||
<strong>{faction(p.faction).name}</strong>
|
||
<small>
|
||
{p.name}
|
||
{p.id === g.me ? ' · You' : ''}
|
||
{p.bot ? ` · ${p.bot}` : ''}
|
||
{p.autopilot ? ` · ${p.autopilot} autopilot` : ''}
|
||
</small>
|
||
</span>
|
||
{p.ready && g.status === 'lobby' && <Check size={16} />}
|
||
</div>
|
||
<p className="fine">
|
||
Player circle {g.playerPositions[p.id]} · Sector{' '}
|
||
{PLAYER_CIRCLE_SECTORS[g.playerPositions[p.id] - 1]}
|
||
</p>
|
||
{p.id === me.id && g.status === 'lobby' && (
|
||
<div className="player-circle-choice">
|
||
<label htmlFor="player-circle">Your player circle</label>
|
||
<select
|
||
id="player-circle"
|
||
aria-describedby="player-circle-help"
|
||
value={g.playerPositions[p.id]}
|
||
disabled={busy}
|
||
onChange={(event) =>
|
||
act({
|
||
type: 'seatPosition',
|
||
position: Number(event.target.value),
|
||
})
|
||
}
|
||
>
|
||
{PLAYER_CIRCLE_SECTORS.map((circleSector, index) => {
|
||
const occupant = g.players.find(
|
||
(player) =>
|
||
player.id !== me.id &&
|
||
g.playerPositions[player.id] === index + 1,
|
||
);
|
||
return (
|
||
<option
|
||
key={circleSector}
|
||
value={index + 1}
|
||
disabled={!!occupant}
|
||
>
|
||
Circle {index + 1} · Sector {circleSector}
|
||
{occupant ? ` · ${occupant.name}` : ''}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
<span className="fine" id="player-circle-help">
|
||
Circles determine storm order. Changing yours resets
|
||
readiness.
|
||
</span>
|
||
</div>
|
||
)}
|
||
{p.bot && g.status === 'lobby' && g.host === me.id && (
|
||
<LobbyBotControls g={g} target={p.id} busy={busy} act={act} />
|
||
)}
|
||
<div className="player-stats">
|
||
<span>{p.reserves} reserves</span>
|
||
<span>{p.tanks} tanks</span>
|
||
{p.noField?.lastUsed !== undefined &&
|
||
p.noField.lastUsed !== null && (
|
||
<span>No-Field {p.noField.lastUsed} · last used</span>
|
||
)}
|
||
{p.elites && (
|
||
<span>
|
||
{Object.values(p.elites.forces).reduce((a, b) => a + b, 0)}{' '}
|
||
elite on Dune
|
||
</span>
|
||
)}
|
||
</div>
|
||
<ForceInspector player={p} worlds={g.homeworlds?.worlds} />
|
||
<FactionInspector key={`${p.faction}:${g.advanced}`} factionId={p.faction} advanced={g.advanced} />
|
||
{p.revealedTraitors.length > 0 && (
|
||
<p className="fine">
|
||
Revealed traitors:{' '}
|
||
{p.revealedTraitors.map(leaderName).join(', ')}
|
||
</p>
|
||
)}
|
||
{!!p.revealedFaceDancers.length && (
|
||
<p className="fine">
|
||
Revealed Face Dancers:{' '}
|
||
{p.revealedFaceDancers.map(leaderName).join(', ')}
|
||
</p>
|
||
)}
|
||
{g.techTokens &&
|
||
ownedTech(g.techTokens, p.id).map((id) => (
|
||
<div className="fine" key={id}>
|
||
{TECH_TOKENS.find((t) => t.id === id)!.name}
|
||
{g.techTokens![id].spice > 0 &&
|
||
` · ${g.techTokens![id].spice} spice at phase end`}
|
||
</div>
|
||
))}
|
||
<RosterAlliance game={g} playerId={p.id} />
|
||
</div>
|
||
))}
|
||
{g.status === 'lobby' &&
|
||
g.host === me.id &&
|
||
!g.twoPlayerControllers &&
|
||
g.players.length < 6 &&
|
||
availableBotFactions.length > 0 && (
|
||
<section className="bot-controls" aria-label="Add an AI player">
|
||
<h3>Add an AI player</h3>
|
||
<label>
|
||
Faction
|
||
<select
|
||
value={nextBotFaction}
|
||
onChange={(e) => setBotFaction(e.target.value)}
|
||
>
|
||
{availableBotFactions.map((f) => (
|
||
<option value={f.id} key={f.id}>
|
||
{f.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Difficulty
|
||
<select
|
||
value={botDifficulty}
|
||
onChange={(e) =>
|
||
setBotDifficulty(e.target.value as Difficulty)
|
||
}
|
||
>
|
||
{DIFFICULTIES.map((d) => (
|
||
<option key={d}>{d}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<p className="fine">{BOT_DESCRIPTIONS[botDifficulty]}</p>
|
||
{actionButton('Add AI player', {
|
||
type: 'addBot',
|
||
faction: nextBotFaction,
|
||
difficulty: botDifficulty,
|
||
})}
|
||
</section>
|
||
)}
|
||
{me.leaders.some((l) => l.capturedBy) && (
|
||
<p className="fine">
|
||
{me.leaders
|
||
.filter((l) => l.capturedBy)
|
||
.map(
|
||
(l) =>
|
||
`${l.name}: ${l.capturedBy === me.id ? 'your captive' : 'captured by Harkonnen'}`,
|
||
)
|
||
.join(' · ')}
|
||
</p>
|
||
)}
|
||
{me.leaders.some((l) => l.gholaBy) && (
|
||
<p className="fine">
|
||
Gholas:{' '}
|
||
{me.leaders
|
||
.filter((l) => l.gholaBy)
|
||
.map(
|
||
(l) =>
|
||
`${l.name} · ${l.gholaBy === me.id ? 'your leader pool' : 'Tleilaxu leader pool'}${l.dead ? ' · in the tanks' : ''}`,
|
||
)
|
||
.join(' / ')}
|
||
</p>
|
||
)}
|
||
{me.kwisatz && (
|
||
<div className="space-y-2"><p className="fine">
|
||
Kwisatz Haderach:{' '}
|
||
{me.kwisatz.dead
|
||
? 'awaiting revival'
|
||
: me.kwisatz.active
|
||
? 'awakened'
|
||
: `${me.kwisatz.losses} of 7 battle losses`}
|
||
{me.kwisatz.usedAt
|
||
? ` · used in ${combatName(me.kwisatz.usedAt)} this turn`
|
||
: ''}
|
||
</p>
|
||
<KwisatzInspector state={me.kwisatz} usedTerritoryName={me.kwisatz.usedAt ? combatName(me.kwisatz.usedAt) : undefined} />
|
||
</div>
|
||
)}
|
||
{g.stormForecast !== null && (
|
||
<div className="space-y-2">
|
||
<p className="fine">Your storm forecast: {g.stormForecast} sectors next turn.</p>
|
||
<StormCardInspector distance={g.stormForecast} context="forecast" />
|
||
</div>
|
||
)}
|
||
<div className="resource-readout">
|
||
<Coins size={18} />
|
||
<strong>{me.spice ?? 0}</strong>
|
||
<span>
|
||
Your spice <HelpTip topic="spice" />
|
||
</span>
|
||
</div>
|
||
<div className="fine">
|
||
Private hands and spice are visible only to their owner.
|
||
</div>
|
||
</aside>
|
||
<section className="board-column">
|
||
<SpiceBankerIncome game={g} />
|
||
<DrawPiles piles={g.drawPiles} />
|
||
<RemovedTreachery cards={g.removedTreachery ?? []} />
|
||
<div className="board-toolbar">
|
||
<span>
|
||
<Wind size={15} />
|
||
Storm · Sector {g.storm}
|
||
</span>
|
||
<div>
|
||
<Button
|
||
variant="ghost"
|
||
aria-label="Zoom out"
|
||
onClick={() => setZoom(Math.max(1, zoom - 0.25))}
|
||
>
|
||
<ZoomOut size={16} />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
aria-label="Zoom in"
|
||
onClick={() => setZoom(Math.min(2.5, zoom + 0.25))}
|
||
>
|
||
<ZoomIn size={16} />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="board-viewport">
|
||
<svg
|
||
className="game-board"
|
||
style={{ width: `${zoom * 100}%`, maxWidth: 'none' }}
|
||
viewBox="-7 -13 1200 1200"
|
||
role="img"
|
||
aria-label="Arrakis territory map"
|
||
>
|
||
<defs>
|
||
<pattern
|
||
id="terrain"
|
||
width="1095"
|
||
height="1095"
|
||
patternUnits="userSpaceOnUse"
|
||
>
|
||
<image
|
||
href="/art/arrakis-terrain.png"
|
||
x="45"
|
||
y="40"
|
||
width="1095"
|
||
height="1095"
|
||
opacity="0.36"
|
||
/>
|
||
</pattern>
|
||
<radialGradient id="sand">
|
||
<stop offset="0%" stopColor="#66593b" />
|
||
<stop offset="100%" stopColor="#353b2b" />
|
||
</radialGradient>
|
||
</defs>
|
||
<circle cx="593" cy="587" r="535" fill="url(#sand)" />
|
||
<circle cx="593" cy="587" r="535" fill="url(#terrain)" />
|
||
{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 (
|
||
<g
|
||
key={`player-circle-${index}`}
|
||
className="board-player-circle"
|
||
transform={`translate(${593 + Math.cos(angle) * 562},${587 + Math.sin(angle) * 562})`}
|
||
>
|
||
<title>{`Player circle ${index + 1}, sector ${circleSector}: ${player
|
||
? `${player.name} · ${faction(player.faction).name}`
|
||
: 'Empty'}`}</title>
|
||
<circle
|
||
r="21"
|
||
fill={player ? faction(player.faction).color : '#171b17'}
|
||
stroke={player?.id === me.id ? '#fff3d6' : '#aa9162'}
|
||
strokeWidth={player?.id === me.id ? 3 : 1.5}
|
||
/>
|
||
<text
|
||
textAnchor="middle"
|
||
dominantBaseline="central"
|
||
fill="#fff3d6"
|
||
fontSize="17"
|
||
fontWeight="700"
|
||
>
|
||
{index + 1}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{g.mobileStronghold?.location && (
|
||
<line
|
||
x1="1045"
|
||
y1="150"
|
||
x2={
|
||
territory(
|
||
splitLocation(g.mobileStronghold.location).territory,
|
||
).center[0]
|
||
}
|
||
y2={
|
||
territory(
|
||
splitLocation(g.mobileStronghold.location).territory,
|
||
).center[1]
|
||
}
|
||
stroke="#a3c8c8"
|
||
strokeDasharray="7 7"
|
||
opacity="0.65"
|
||
pointerEvents="none"
|
||
/>
|
||
)}
|
||
{gameTerritories(g).map((t) => (
|
||
<g
|
||
key={t.id}
|
||
className={`territory ${t.type} ${selected === t.id ? 'selected' : ''}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label={`Select ${t.name}`}
|
||
onClick={() => choose(t.id)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
choose(t.id);
|
||
}
|
||
}}
|
||
>
|
||
<polygon
|
||
points={t.points.map((p) => p.join(',')).join(' ')}
|
||
/>
|
||
<text x={t.center[0]} y={t.center[1]} textAnchor="middle">
|
||
{t.name}
|
||
</text>
|
||
</g>
|
||
))}
|
||
{Array.from({ length: 18 }, (_, i) => {
|
||
const a = (Math.PI / 180) * (120 - i * 20);
|
||
return (
|
||
<g key={i}>
|
||
<line
|
||
x1={593}
|
||
y1={587}
|
||
x2={593 + Math.cos(a) * 535}
|
||
y2={587 + Math.sin(a) * 535}
|
||
stroke="#dccb9666"
|
||
strokeDasharray="3 8"
|
||
pointerEvents="none"
|
||
/>
|
||
<text
|
||
x={593 + Math.cos(a - Math.PI / 18) * 516}
|
||
y={587 + Math.sin(a - Math.PI / 18) * 516}
|
||
className="sector-label"
|
||
>
|
||
{i + 1}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
<g
|
||
className="storm-overlay"
|
||
style={{
|
||
transform: `rotate(${-(g.storm - 1) * 20}deg)`,
|
||
transformOrigin: '593px 587px',
|
||
}}
|
||
pointerEvents="none"
|
||
>
|
||
<path
|
||
d="M593 587 L325.5 1050.3 A535 535 0 0 0 500.1 1113.9 Z"
|
||
fill="#d3a24655"
|
||
stroke="#d7b066"
|
||
strokeWidth="2"
|
||
/>
|
||
</g>
|
||
{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) => (
|
||
<g
|
||
key={`${t.id}-${p.id}`}
|
||
pointerEvents="none"
|
||
className={`force-token${isAdvisor(p, t.id) ? ' advisor-token' : ''}`}
|
||
aria-label={`${p.name}: ${isAdvisor(p, t.id) ? 'advisors' : 'fighters'}`}
|
||
transform={`translate(${t.center[0] + (i - (present.length - 1) / 2) * 27},${t.center[1] + 23})`}
|
||
>
|
||
<circle r="13" fill={faction(p.faction).color} />
|
||
{isAdvisor(p, t.id) && (
|
||
<circle
|
||
r="16"
|
||
fill="none"
|
||
stroke="#eee8d6"
|
||
strokeDasharray="3 3"
|
||
strokeWidth="2"
|
||
/>
|
||
)}
|
||
<text textAnchor="middle" y="5">
|
||
{Object.entries(p.forces)
|
||
.filter(([k]) => splitLocation(k).territory === t.id)
|
||
.reduce((n, [, v]) => n + v, 0)}
|
||
</text>
|
||
</g>
|
||
));
|
||
})}
|
||
<NoFieldBoardMarkers players={g.players} />
|
||
<TerrorBoardMarkers tokens={g.moritaniTerror?.tokens ?? []} atomics={g.moritaniAtomics} />
|
||
<AmbassadorBoardMarkers tokens={g.ambassadors?.tokens ?? []} />
|
||
{Object.entries(g.spice)
|
||
.filter(([, n]) => n > 0)
|
||
.map(([k, n]) => {
|
||
const t = territory(splitLocation(k).territory);
|
||
return (
|
||
<g
|
||
key={battleCardLabel(k)}
|
||
pointerEvents="none"
|
||
transform={`translate(${t.center[0]},${t.center[1] - 25})`}
|
||
>
|
||
<rect
|
||
x="-17"
|
||
y="-12"
|
||
width="34"
|
||
height="20"
|
||
rx="4"
|
||
fill="#d3a94e"
|
||
/>
|
||
<text textAnchor="middle" y="3" className="spice-amount">
|
||
◆ {n}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
</div>
|
||
<HomeworldTable game={g} />
|
||
<div className="territory-detail">
|
||
<div>
|
||
<span className="eyebrow">
|
||
{territory(selected).type.toUpperCase()}
|
||
</span>
|
||
<h3>{territory(selected).name}</h3>
|
||
</div>
|
||
<span className="fine">
|
||
{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(' · ')}`}
|
||
</span>
|
||
</div>
|
||
<div className="territory-units">
|
||
{g.players.flatMap((p) =>
|
||
Object.entries(p.forces)
|
||
.filter(([k]) => splitLocation(k).territory === selected)
|
||
.map(([k, n]) => (
|
||
<span
|
||
key={`${p.id}-${k}`}
|
||
style={{ color: faction(p.faction).color }}
|
||
>
|
||
{faction(p.faction).name}: {n}{' '}
|
||
{isAdvisor(p, selected) ? 'advisors' : 'forces'} in sector{' '}
|
||
{splitLocation(k).sector}
|
||
</span>
|
||
)),
|
||
)}
|
||
{g.players.flatMap((p) => {
|
||
const marker = p.noField?.deployed;
|
||
if (!marker || !isBoardNoFieldLocation(marker.location) || marker.location.territory !== selected)
|
||
return [];
|
||
return [<span
|
||
key={`no-field-detail-${p.id}`}
|
||
style={{ color: faction(p.faction).color }}
|
||
>
|
||
{faction(p.faction).name}: concealed No-Field in sector{' '}
|
||
{marker.location.sector} · counts as one force
|
||
</span>];
|
||
})}
|
||
</div>
|
||
{g.status !== 'lobby' && (
|
||
<section
|
||
className="spice-card-table"
|
||
aria-label="Visible spice cards"
|
||
>
|
||
{g.playedOrnithopter && (
|
||
<article className="notice" aria-label="Played movement card">
|
||
<h3>Ornithopter in play</h3>
|
||
<p>
|
||
{
|
||
g.players.find(
|
||
(p) => p.id === g.playedOrnithopter!.player,
|
||
)?.name
|
||
}{' '}
|
||
·{' '}
|
||
{g.playedOrnithopter.mode === 'range3'
|
||
? 'Three-territory group'
|
||
: 'Two different groups'}{' '}
|
||
· {g.playedOrnithopter.completed} completed
|
||
</p>
|
||
<CardInspector card={g.playedOrnithopter.card} />
|
||
</article>
|
||
)}
|
||
<h3>Spice cards on the table</h3>
|
||
<div className="spice-card-fronts">
|
||
{g.spiceDiscardTop
|
||
.slice(0, g.advanced ? 2 : 1)
|
||
.map((card, index) => (
|
||
<article key={index} className="spice-card-front">
|
||
<span className="eyebrow">
|
||
{g.advanced ? `Spice pile ${index + 1}` : 'Spice pile'}{' '}
|
||
· top card
|
||
</span>
|
||
{card ? (
|
||
<>
|
||
<strong>{spiceCardTitle(card)}</strong>
|
||
<SpiceCardInspector
|
||
card={card}
|
||
context={`Public spice pile ${index + 1}`}
|
||
/>
|
||
</>
|
||
) : (
|
||
<p className="fine">No face-up card yet.</p>
|
||
)}
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</section>
|
||
<aside className="action-panel" id="table-decisions" tabIndex={-1}>
|
||
<div className="eyebrow">
|
||
{g.status === 'finished' ? 'FINAL OUTCOME' : 'YOUR NEXT DECISION'}
|
||
</div>
|
||
{g.battle?.revealed && <a className="battle-display-link" href="#revealed-battle-plans">Compare revealed battle plans</a>}
|
||
<PrivateBattlePlan game={g} />
|
||
<NexusFremenRideBetrayal game={g} act={act} busy={reactionBusy} />
|
||
<HomeworldEcazDuke game={g} act={act} busy={reactionBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
<MovementPromises game={g} act={act} busy={reactionBusy} />
|
||
<MovementExpressionPromises game={g} send={act} busy={reactionBusy} />
|
||
<MixedMovementPromises game={g} send={act} busy={reactionBusy} />
|
||
{!reactionOwnsControls && g.semutaReaction && <SemutaReaction game={g} act={act} busy={reactionBusy} />}
|
||
{g.karamaBlocked && <p className="notice" role="status">{g.karamaBlocked}</p>}
|
||
{g.karamaPurposeBlocks?.map(({purpose, message}) => <p key={purpose} className="notice" role="status">{message}</p>)}
|
||
{!reactionOwnsControls && <>
|
||
<NexusCards game={g} act={act} busy={busy} />
|
||
<Recruits game={g} act={act} busy={busy} />
|
||
<AdvisorPreparation game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
{g.decision?.kind !== 'fullPlanOffer' && <AtreidesFullPlan game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />}
|
||
{g.decision?.kind !== 'revivalStop' && <TleilaxuSpecialKarama game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />}
|
||
<NexusTraitors game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
<DiscoveryPanel game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
<RihaniHistory game={g} />
|
||
<MentatHistory game={g} />
|
||
<NexusChoamTrade game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
<NexusChoamBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
<NexusMoritaniBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
<NexusEcazBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
<NexusFremenBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
<NexusEcazInquiry game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
<NexusEcazDuke game={g} act={act} busy={busy} />
|
||
<NexusTleilaxu game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
<NexusSuboids game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
<NexusAdvisors game={g} act={act} busy={busy} />
|
||
<NexusSardaukar game={g} act={act} busy={busy} />
|
||
{!g.nexusCards?.waiting.length && <TupileIntelligence game={g} act={act} busy={busy} />}
|
||
{g.biddingEnd && <BiddingEnd game={g} act={act} busy={busy} />}
|
||
{g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && (
|
||
<JunctionTransport key={me.id === g.junctionTransport.owner ? g.junctionTransport.offerEvent : g.junctionTransport.event} game={g} act={act} busy={busy} />
|
||
)}
|
||
<RicheseNoFieldControls
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
destination={selected}
|
||
sector={sector}
|
||
allyPayment={allyPayment}
|
||
/>
|
||
<RicheseAlliedNoFieldControls
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
destination={selected}
|
||
sector={sector}
|
||
onSectorChange={setSector}
|
||
/>
|
||
</>}
|
||
{g.status === 'playing' && g.phase < 5 && (
|
||
<ShipmentPromises game={g} act={act} busy={busy} />
|
||
)}
|
||
{g.status === 'playing' && <TruthCharityPromise game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <BribePromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <BribeExpressionPromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <MixedBribePromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <AlliancePromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <AllianceExpressionPromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <MixedAlliancePromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <RevivalExpressionPromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <MixedRevivalPromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <LeaderRevivalPromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <RevivalPromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <PhaseBidPromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <EarlyRicheseBidPromises game={g} />}
|
||
{g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />}
|
||
{g.status === 'playing' && <MixedBattlePromises game={g} />}
|
||
{g.status === 'playing' && <FutureBattleLeaderPromises game={g} />}
|
||
{g.status === 'playing' && g.shortAllianceVictory && <p className="notice">
|
||
Optional alliance rule: a two-member alliance wins with three strongholds when at least three people or AI controllers are playing. Two-controller games retain their four-stronghold target.
|
||
</p>}
|
||
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
|
||
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
|
||
{g.status === 'playing' && <TurnActionPromises game={g} busy={g.semutaReaction &&
|
||
['semutaCommit', 'semutaSelect', 'semutaPass'].includes(g.turnActionGuide?.action.type ?? '')
|
||
? reactionBusy || reactionOwnsControls : busy} act={act} fillPlan={fillBattlePlan} />}
|
||
{g.status === 'playing' && g.phase === 3 && g.auction && (
|
||
<AuctionCountdown clock={g.auctionClock}
|
||
playerName={g.players.find(player => player.id === g.auction!.active)?.name ?? 'The current bidder'}
|
||
paused={g.roomControl?.paused || g.roomControl?.closed} />
|
||
)}
|
||
{g.guildBetrayalReaction ? (
|
||
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
|
||
) : g.richeseBetrayalReaction ? (
|
||
<NexusRicheseBetrayal game={g} act={act} busy={reactionBusy} />
|
||
) : g.kullReaction ? (
|
||
<ChoamKull game={g} act={act} busy={reactionBusy} />
|
||
) : g.nexusIxianReplacement ? (
|
||
<NexusIxianReplacement game={g} act={act} busy={reactionBusy} />
|
||
) : ixianBetrayalOwnsControls ? (
|
||
<NexusIxianBetrayal game={g} act={act} busy={reactionBusy} />
|
||
) : harkonnenBetrayalOwnsControls ? (
|
||
<NexusHarkonnenBetrayal game={g} act={act} busy={reactionBusy} />
|
||
) : g.nexusCards?.waiting.length ? (
|
||
<p className="muted">The next phase begins when the remaining Nexus card choices are finished.</p>
|
||
) : g.truthtrance ? (
|
||
<Truthtrance
|
||
key={`${g.truthtrance.queue[0]?.card}-${g.truthtrance.stage}`}
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
/>
|
||
) : g.status === 'lobby' ? (
|
||
<>
|
||
<h2>Assemble the houses</h2>
|
||
<p className="muted">
|
||
Share the invite above. Every player chooses a faction and
|
||
confirms they are ready.
|
||
</p>
|
||
<RulesetControls
|
||
advanced={g.advanced}
|
||
disabled={busy}
|
||
lobby
|
||
host={g.host === me.id}
|
||
onChange={(advanced) => act({ type: 'rules', advanced })}
|
||
/>
|
||
<label>
|
||
Your faction
|
||
<select
|
||
value={me.faction}
|
||
disabled={busy}
|
||
onChange={(e) =>
|
||
act({ type: 'faction', faction: e.target.value })
|
||
}
|
||
>
|
||
{FACTIONS.filter(
|
||
(f) =>
|
||
f.expansion === 'base' ||
|
||
g.expansions.includes(f.expansion),
|
||
).map((f) => (
|
||
<option
|
||
disabled={g.players.some(
|
||
(p) => p.id !== me.id && p.faction === f.id,
|
||
)}
|
||
value={f.id}
|
||
key={f.id}
|
||
>
|
||
{f.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="checkbox-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={!!g.techTokens}
|
||
disabled={busy || g.host !== me.id}
|
||
onChange={(e) =>
|
||
act({ type: 'techTokens', enabled: e.target.checked })
|
||
}
|
||
/>
|
||
Tech tokens · optional, 3+ players{' '}
|
||
<HelpTip topic="techTokens" />
|
||
</label>
|
||
<label className="checkbox-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={!!g.strongholdCards}
|
||
disabled={busy || g.host !== me.id || !g.advanced}
|
||
onChange={(e) =>
|
||
act({ type: 'strongholdCards', enabled: e.target.checked })
|
||
}
|
||
/>
|
||
Stronghold Cards · Advanced rules required
|
||
</label>
|
||
{g.lobbyModules && ([
|
||
['leaderSkills', 'Leader Skills · all fourteen skill cards'],
|
||
['ecazTreachery', 'Ecaz Treachery Cards · Recruits, Reinforcements, Harass & Withdraw'],
|
||
['nexusCards', 'Nexus Cards'],
|
||
['discoveries', 'Discoveries'],
|
||
] as const).map(([module, label]) => (
|
||
<label className="checkbox-row" key={module}>
|
||
<input
|
||
type="checkbox"
|
||
checked={g.lobbyModules![module]}
|
||
disabled={busy || g.host !== me.id}
|
||
onChange={(e) => act({ type: 'moduleOption', module, enabled: e.target.checked })}
|
||
/>
|
||
{label} · preview
|
||
</label>
|
||
))}
|
||
<label className="checkbox-row">
|
||
<input type="checkbox" checked={!!g.lobbyModules?.kullPreview}
|
||
disabled={busy || g.host !== me.id}
|
||
onChange={(e) => act({ type: 'moduleOption', module: 'kullPreview', enabled: e.target.checked })} />
|
||
Kull Wahad · {g.advanced ? 'Advanced' : 'Basic'} development preview
|
||
</label>
|
||
{g.lobbyModules?.kullPreview && <p className="notice">
|
||
{g.advanced ? <>
|
||
Requires Advanced CHOAM with only classic factions, the CHOAM and Ix decks, and no other optional modules.
|
||
This preview prevents the attempted specific Karama effect; phase duration is provisional.
|
||
Structured bid, alliance, Revival, bribe and combined turn-action questions are unavailable.
|
||
</> : <>
|
||
Requires Basic CHOAM with the CHOAM and Ix decks plus each faction’s deck, and no other optional modules.
|
||
Combined turn-action Truthtrance questions are unavailable.
|
||
When Richese is seated, mixed current-lot bid, alliance, Revival and bribe commitments are also unavailable,
|
||
and some Kull reactions may be unavailable in complex response windows.
|
||
</>}{' '}
|
||
Unfunded non-CHOAM Karama-dependent bids remain deferred. This does not represent complete CHOAM rules.
|
||
Every player must ready again after changing it.
|
||
</p>}
|
||
{g.lobbyModules?.kullDefault && <p className="notice">
|
||
{g.lobbyModules.kullPreview
|
||
? 'Printed Kull is enabled by default for this supported Basic preview. The host can turn it off to retain the broader non-Kull Truthtrance options.'
|
||
: 'The host has turned off the printed Kull preview. Broader non-Kull Truthtrance options remain available.'}
|
||
</p>}
|
||
{!g.lobbyModules?.kullDefault && !g.lobbyModules?.kullPreview &&
|
||
g.expansions.includes('choam') && g.expansions.includes('ix') &&
|
||
g.players.some(player => player.faction === 'choam') && <p className="notice">
|
||
Printed Kull is inactive in this lobby. Its preview requires a supported {g.advanced ? 'Advanced' : 'Basic'} configuration and may need different module selections.
|
||
</p>}
|
||
<label className="checkbox-row">
|
||
<input type="checkbox" checked={g.shortAllianceVictory} disabled={busy || g.host !== me.id}
|
||
onChange={(e) => act({type: 'shortAllianceVictory', enabled: e.target.checked})} />
|
||
Optional: two-member alliances win with three strongholds (three or more controllers)
|
||
</label>
|
||
<label className="checkbox-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={!!g.homeworlds}
|
||
disabled={busy || g.host !== me.id}
|
||
onChange={(e) => act({ type: 'homeworlds', enabled: e.target.checked })}
|
||
/>
|
||
Homeworlds · preview
|
||
</label>
|
||
{actionButton(
|
||
me.ready ? 'Not ready' : g.advanced ? 'Ready for Advanced preview' : 'Ready to play',
|
||
{ type: 'ready' },
|
||
)}
|
||
{g.host === me.id &&
|
||
actionButton(
|
||
g.advanced ? 'Begin Advanced preview' : 'Begin game',
|
||
g.advanced
|
||
? { type: 'start', advancedPreview: true }
|
||
: { type: 'start' },
|
||
g.players.length < (g.techTokens ? 3 : 2) ||
|
||
!g.players.every((p) => p.ready),
|
||
)}
|
||
<p className="development-note">
|
||
Playable core under development. Advanced and expansion factions
|
||
are unfinished previews; some interactions may still need fixes.
|
||
</p>
|
||
</>
|
||
) : g.status === 'setup' && !g.decision ? (
|
||
<>
|
||
<h2>
|
||
{setupStage === 'leaderSkills'
|
||
? 'Assign your Leader Skill'
|
||
: setupStage === 'prediction'
|
||
? 'Seal the prediction'
|
||
: setupStage === 'traitors'
|
||
? 'Choose your traitor'
|
||
: setupStage === 'forces'
|
||
? 'Place starting forces'
|
||
: 'Secrets & allegiances'}
|
||
</h2>
|
||
<p className="muted" role="status">
|
||
{setupStatus}
|
||
</p>
|
||
{setupStage && (
|
||
<p className="fine">
|
||
{g.leaderSkills
|
||
? 'Setup order: prediction, starting Treachery Cards, Leader Skills, traitors, starting forces, then Storm.'
|
||
: 'Setup order: prediction, traitors, starting forces, then the automatic treachery-card deal and Storm phase.'}
|
||
</p>
|
||
)}
|
||
<EcazSetup game={g} act={act} busy={busy} />
|
||
{(!setupStage || setupStage === 'forces') &&
|
||
g.advanced &&
|
||
me.faction === 'beneGesserit' &&
|
||
!me.advisorSetup && (
|
||
<>
|
||
<p className="muted">
|
||
After the other starting force choices, choose a territory and sector on
|
||
the map for your starting advisor. It becomes a fighter if
|
||
alone.
|
||
</p>
|
||
{destination}
|
||
{(waitingForFremen || waitingForEcaz) && (
|
||
<p className="fine" id="advisor-setup-wait">
|
||
{waitingForFremen ? setupFremen!.name : setupEcaz!.name} must finish placing starting forces
|
||
before you place an advisor.
|
||
</p>
|
||
)}
|
||
{selected === MOBILE_STRONGHOLD && (
|
||
<p className="fine">
|
||
Choose a territory on the printed board for your
|
||
starting advisor.
|
||
</p>
|
||
)}
|
||
{actionButton(
|
||
'Place starting advisor',
|
||
{ type: 'advisorSetup', territory: selected, sector },
|
||
waitingForFremen ||
|
||
waitingForEcaz ||
|
||
me.reserves !== 20 ||
|
||
selected === MOBILE_STRONGHOLD,
|
||
waitingForFremen || waitingForEcaz ? 'advisor-setup-wait' : undefined,
|
||
)}
|
||
</>
|
||
)}
|
||
{(!setupStage || setupStage === 'traitors') &&
|
||
!!me.traitorChoices?.length && (
|
||
<>
|
||
<p className="muted">
|
||
Keep one traitor. Your selection remains secret.
|
||
</p>
|
||
<div className="setup-traitor-choices">
|
||
{me.traitorChoices.map((id) => (
|
||
<div key={id} className="traitor-choice">
|
||
<strong>{leaderName(id)}</strong>
|
||
{inspectIdentity(id, 'traitor')}
|
||
{actionButton(`Keep ${leaderName(id)}`, {
|
||
type: 'traitor',
|
||
leader: id,
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
{(!setupStage || setupStage === 'forces') &&
|
||
me.faction === 'fremen' &&
|
||
me.reserves === 20 && (
|
||
<>
|
||
<p>Distribute ten forces.</p>
|
||
{FREMEN_START_LOCATIONS.map((key) => {
|
||
const loc = splitLocation(key);
|
||
return (
|
||
<label key={key} htmlFor={`fremen-${key}`}>
|
||
{territory(loc.territory).name} · Sector {loc.sector}
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
max={10}
|
||
id={`fremen-${key}`}
|
||
value={fremen[key] ?? 0}
|
||
onChange={(e) =>
|
||
setFremen({
|
||
...fremen,
|
||
[key]: Number(e.target.value),
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
);
|
||
})}
|
||
{me.elites &&
|
||
FREMEN_START_LOCATIONS.map((key) => (
|
||
<EliteCount
|
||
key={key}
|
||
label={`Fedaykin in ${territory(splitLocation(key).territory).name}, sector ${splitLocation(key).sector}`}
|
||
max={Math.min(3, fremen[key] ?? 0)}
|
||
value={eliteSetup[key] ?? 0}
|
||
onChange={(n) =>
|
||
setEliteSetup({ ...eliteSetup, [key]: n })
|
||
}
|
||
/>
|
||
))}
|
||
<p className="fine">
|
||
{Object.values(fremen).reduce((a, b) => a + b, 0)} / 10
|
||
forces assigned
|
||
</p>
|
||
{actionButton(
|
||
'Place forces',
|
||
{
|
||
type: 'fremenSetup',
|
||
placements: fremen,
|
||
elitePlacements: eliteSetup,
|
||
},
|
||
Object.values(fremen).reduce((a, b) => a + b, 0) !== 10,
|
||
)}
|
||
</>
|
||
)}
|
||
{(!setupStage || setupStage === 'prediction') &&
|
||
me.faction === 'beneGesserit' &&
|
||
!me.prediction && (
|
||
<>
|
||
<label>
|
||
Predicted victor
|
||
<select
|
||
value={target}
|
||
onChange={(e) => setTarget(e.target.value)}
|
||
>
|
||
<option value="">Choose a faction</option>
|
||
{g.players
|
||
.filter((p) => p.id !== me.id)
|
||
.map((p) => (
|
||
<option key={p.id} value={p.faction}>
|
||
{faction(p.faction).name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label htmlFor="predicted-turn">
|
||
Predicted turn
|
||
<Input
|
||
id="predicted-turn"
|
||
type="number"
|
||
min={1}
|
||
max={10}
|
||
value={turn}
|
||
onChange={(e) => setTurn(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
{actionButton(
|
||
'Seal prediction',
|
||
{ type: 'predict', faction: target, turn },
|
||
!g.players.some(
|
||
(p) => p.id !== me.id && p.faction === target,
|
||
) ||
|
||
!Number.isInteger(turn) ||
|
||
turn < 1 ||
|
||
turn > 10,
|
||
)}
|
||
</>
|
||
)}
|
||
{!setupStage && (
|
||
<p className="fine">
|
||
Play begins when everyone has completed their starting
|
||
choices.
|
||
</p>
|
||
)}
|
||
</>
|
||
) : g.status === 'finished' ? (
|
||
<>
|
||
<h2>
|
||
{g.winner.includes(me.id)
|
||
? 'Arrakis is yours.'
|
||
: 'The struggle is over.'}
|
||
</h2>
|
||
<p className="notice">
|
||
{g.winner
|
||
.map(
|
||
(id) =>
|
||
faction(g.players.find((p) => p.id === id)!.faction).name,
|
||
)
|
||
.join(' and ')}{' '}
|
||
won on turn {g.turn}.
|
||
</p>
|
||
<p className="muted">
|
||
The battle record remains available below.
|
||
</p>
|
||
</>
|
||
) : g.phaseOpening ? (
|
||
<>
|
||
<span className="eyebrow">Phase opening</span>
|
||
<h2>Before {PHASES[g.phase]}</h2>
|
||
<p>
|
||
Play Amal now, or pass to begin this phase. Phase effects wait
|
||
until everyone has passed.
|
||
</p>
|
||
{me.hand
|
||
?.filter((c) => c.effect === 'amal')
|
||
.map((c) => (
|
||
<div key={c.id}>
|
||
<p>
|
||
Amal returns half of every faction’s available spice to
|
||
the bank, rounded up. Your loss:{' '}
|
||
{Math.ceil((me.spice ?? 0) / 2)} spice.
|
||
</p>
|
||
{actionButton(
|
||
'Play Amal',
|
||
{ type: 'card', card: c.id },
|
||
g.phaseOpening!.passed.includes(me.id),
|
||
)}
|
||
</div>
|
||
))}
|
||
{actionButton(
|
||
'Pass phase opening',
|
||
{ type: 'ready' },
|
||
g.phaseOpening.passed.includes(me.id),
|
||
)}
|
||
<p className="fine">
|
||
{g.phaseOpening.passed.length} / {g.players.length} passed.
|
||
Playing Amal asks everyone to confirm again.
|
||
</p>
|
||
</>
|
||
) : g.response ? (
|
||
<>
|
||
<span className="eyebrow">Power response</span>
|
||
<h2>
|
||
{
|
||
{
|
||
moritaniAlliance: 'Moritani · Enemy of My Enemy',
|
||
moritaniRetention: 'Moritani · allied card retention',
|
||
moritaniDuke: 'Moritani · Duke Vidal acquisition',
|
||
ecazPlacement: 'Ecaz Ambassador placement',
|
||
ecazCollection: 'Ecaz stronghold collection',
|
||
ecazOccupy: 'Ecaz combined-army Occupy',
|
||
moritaniPlacement:
|
||
'Moritani Terror placement or relocation',
|
||
choamRevival: 'CHOAM force revival',
|
||
revivalLimit: 'Expanded force revival',
|
||
revivalDiscount: 'Half-price revival',
|
||
earlyRevival: 'Early leader revival',
|
||
revivalIncome: 'Tleilaxu revival payment',
|
||
foreignGhola: 'Tleilaxu foreign ghola revival',
|
||
ixSubstitution: 'Suboid casualty substitution',
|
||
ixMovement: 'Cyborg movement',
|
||
fremenMovement: 'Fremen movement',
|
||
ixAuction: 'Ixian auction inspection',
|
||
richeseAuction: 'Richese cache auction',
|
||
richeseBlackMarket: 'Richese Black Market offer',
|
||
richesePurchaseIncome: 'Emperor card-purchase income',
|
||
richeseGift: 'Richese allied card gift',
|
||
ixTechnology: 'Ixian auction substitution',
|
||
ixAllyCard: 'Ixian allied purchase replacement',
|
||
mobileStronghold: 'Mobile stronghold relocation',
|
||
faceDancerReplacement: 'Replace an unrevealed Face Dancer',
|
||
voice: 'The Voice',
|
||
prescience: 'Prescience',
|
||
nexusPrescience: 'Atreides Nexus Cunning',
|
||
emperorIncome:
|
||
g.response.source === 'ambassador'
|
||
? 'Emperor card-purchase income'
|
||
: 'Emperor auction income',
|
||
harkonnenBonus: 'Harkonnen bonus card',
|
||
harkonnenTraitor: 'Harkonnen ally traitor',
|
||
guildIncome: 'Guild shipment income',
|
||
guildRate: 'Guild half-price shipment rate',
|
||
guildTransportPermission: 'Guild cross/return transport permission',
|
||
richeseNoField: 'Richese No-Field shipment',
|
||
advisor: 'Spiritual advisor',
|
||
emperorGift: 'Emperor spice gift',
|
||
emperorRevival: 'Emperor extra revival',
|
||
fremenRevivalGrant: 'Fremen allied free revival',
|
||
bgCharity: 'Bene Gesserit charity',
|
||
choamCharity: 'CHOAM charity income',
|
||
choamInflation: 'CHOAM Inflation placement',
|
||
choamSale: 'CHOAM card sale',
|
||
choamWorthless: 'CHOAM Worthless card effect',
|
||
choamBattleIncome: 'CHOAM battle force income',
|
||
choamAudit: 'CHOAM Auditor inspection',
|
||
choamBattleAid: 'CHOAM allied combat funding',
|
||
worthlessKarama: 'Worthless card as Karama',
|
||
advisorFlip: 'Bene Gesserit token flip',
|
||
nexusAdvisorFlip: 'Bene Gesserit Nexus advisor conversion',
|
||
nexusSardaukar: 'Emperor Nexus temporary Sardaukar',
|
||
eliteStrength: 'Elite battle strength',
|
||
fremenSupport: 'Fremen full strength without spice',
|
||
capture: 'Harkonnen leader capture',
|
||
kwisatz: 'Kwisatz Haderach battle protection',
|
||
guildTiming: 'Guild shipment and movement timing',
|
||
nexusGuildCunning: 'Guild Cunning second shipment',
|
||
nexusFremenCunning: 'Fremen Cunning remote worm ride',
|
||
nexusEcazBetrayal: 'Ecaz Nexus forced ally return',
|
||
atreidesAuction: 'Atreides auction foresight',
|
||
atreidesSpice: 'Atreides spice foresight',
|
||
stormPeek: 'Fremen storm foresight',
|
||
stormProtection: 'Fremen storm survival',
|
||
wormPlacement: 'Fremen additional worm placement',
|
||
wormSurvival: 'Fremen worm survival',
|
||
wormAllyProtection: 'Fremen allied worm protection',
|
||
}[g.response.kind]
|
||
}
|
||
</h2>
|
||
<EcazOccupyBattleSummary game={g} />
|
||
{g.paymentIncome?.low && (
|
||
<p className="notice">
|
||
Of the {g.paymentIncome.gross} spice payment,{' '}
|
||
{g.players.find((player) => player.id === g.paymentIncome?.owner)?.name}
|
||
{' '}receives {g.paymentIncome.income}. Low-population{' '}
|
||
{g.paymentIncome.kind === 'shipment' ? 'Junction' : 'Kaitain'}
|
||
{' '}leaves {g.paymentIncome.bank} spice in the bank. The payer’s cost stays the same.
|
||
</p>
|
||
)}
|
||
{g.response.automaticAdvisorFlip && (
|
||
<p className="notice">
|
||
{g.response.automaticAdvisorFlip.producer === 'guildTransport'
|
||
? 'The transport and payment are complete. These advisors are now alone and must become fighters.'
|
||
: g.response.automaticAdvisorFlip.producer === 'gamont'
|
||
? 'Trip to Gamont is complete and its card use stays spent. These advisors are now alone and must become fighters before victory is checked.'
|
||
: g.response.automaticAdvisorFlip.producer === 'battleLosses'
|
||
? 'Battle losses and mandatory card disposal are complete. These surviving advisors are now alone and must become fighters before the remaining cleanup.'
|
||
: g.response.automaticAdvisorFlip.producer === 'homeworldTransport'
|
||
? 'The Homeworld transport and payment are complete. These advisors are now alone and must become fighters before any remaining arrival.'
|
||
: g.response.automaticAdvisorFlip.producer === 'faceDance'
|
||
? 'Face Dance is complete: the winning army has returned, the replacement is placed and the Face Dancer is revealed. These surviving advisors are now alone and must become fighters before aftermath continues.'
|
||
: g.response.automaticAdvisorFlip.producer === 'storm'
|
||
? `Storm casualties in sector ${g.response.automaticAdvisorFlip.storm.sector} are complete. These surviving advisors are now alone and must become fighters before the remaining storm traversal.`
|
||
: g.response.automaticAdvisorFlip.producer === 'specialRelocation'
|
||
? `${g.response.automaticAdvisorFlip.relocation.kind === 'ambassador' ? 'Fremen Ambassador relocation' : 'The free Discovery force transfer'} is complete. These advisors are now alone and must become fighters before the original arrival continues.`
|
||
: g.response.automaticAdvisorFlip.producer === 'spiritualArrival'
|
||
? 'The free Spiritual Advisor packet has arrived and its reserves and any earned income stay committed. These advisors are now alone and must become fighters before the original arrival continues.'
|
||
: 'The ground movement is complete. These advisors are now alone and must become fighters.'}
|
||
{' '}Karama prevents only this conversion; the completed action and its costs stay committed.
|
||
{' '}Provisional rule: a prevented group stays advisors while continuously alone,
|
||
until another faction returns or the group leaves.
|
||
</p>
|
||
)}
|
||
{g.response.advisorMatchingSource && (
|
||
<p className="notice">
|
||
The incoming group must become {g.response.advisorMatchingSource.advisors ? 'advisors' : 'fighters'} to match your forces already in the destination.
|
||
{' '}This {g.response.advisorMatchingSource.kind === 'movement' ? 'movement' : 'shipment'} has not happened yet.
|
||
{' '}Karama prevents this group from matching here during this action opportunity. Residents keep their type; no forces, shipment payment or unused action are spent.
|
||
{' '}Any already-played movement or declaration card keeps its original use. Choose a legal replacement if matching is prevented.
|
||
</p>
|
||
)}
|
||
{g.response.kind==='advisorFlip'&&g.response.advisorGatherMatchingSource&&(
|
||
<p className="notice">
|
||
Gathered group {g.response.advisorGatherMatchingPosition?.current} of {g.response.advisorGatherMatchingPosition?.total}:
|
||
{' '}the incoming forces from {territory(g.response.advisorGatherMatchingSource.origin).name} must match your residents.
|
||
{' '}Neither origin has moved, changed type or collected spice. Every required group response must allow the same original gather before it moves once.
|
||
{' '}Preventing one group leaves both origins and the movement unused, while this prevented packet here remains unavailable. Choose a legal replacement.
|
||
</p>
|
||
)}
|
||
{g.response.kind==='advisorFlip'&&g.response.advisorTransportMatchingSource&&(
|
||
<p className="notice">
|
||
The incoming transport group must match your residents before payment or force transfer.
|
||
{' '}Preventing matching leaves the shipment unused, while this same packet here stays unavailable for the original opportunity.
|
||
{' '}Choose another legal packet or destination.
|
||
{g.response.advisorTransportMatchingSource.producer==='junction'
|
||
? ' The Junction permission and tariff remain immune; the original payment and transfer follow allowance.'
|
||
: ' Original transport permission, price and payment follow separately if matching is allowed.'}
|
||
</p>
|
||
)}
|
||
{g.response.kind==='advisorFlip'&&g.response.advisorAmbassadorMatchingSource&&(
|
||
<p className="notice">
|
||
The incoming Fremen Ambassador group must match your resident forces. No selected force has moved or collected spice yet.
|
||
{' '}Preventing matching restores the original relocation choice and blocks this same packet here for this Ambassador event.
|
||
{' '}The trigger stays committed; your ordinary shipment and movement remain unchanged.
|
||
</p>
|
||
)}
|
||
{g.response.kind==='advisorFlip'&&g.response.spiritualAdvisorSource&&(
|
||
<p className="notice">
|
||
The free advisor packet must become fighters to match your Polar Sink residents. No free force has arrived yet.
|
||
{' '}Preventing matching restores your original free-shipment choice; that same packet here remains unavailable for this opportunity.
|
||
{' '}The other faction’s completed shipment stays committed. Choose another legal packet or decline.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'advisor' && (
|
||
<p className="notice">
|
||
{g.players.find((player) => player.id === g.response?.owner)?.name} declared {g.response.amount ?? 1} free {(g.response.amount ?? 1) === 1 ? 'force' : 'forces'} to {territory(splitLocation(g.response.location ?? 'polar_sink:0').territory).name}.
|
||
{' '}Allow the arrival or cancel Spiritual Advisors. Canceling prevents the whole declared group.
|
||
{g.response.spiritualAdvisorSource&&<> Matching or an alone conversion may follow separately. The other faction’s shipment is already committed.</>}
|
||
{g.response.spiritualAdvisorSource?.revivalEvent&&<> The revived group stays placed; this response does not cancel its revival.</>}
|
||
{g.response.spiritualAdvisorSource?.victoryEvent&&<> The Sneak Attack group stays placed; this response does not replay or refund its arrival.</>}
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'moritaniRetention' &&
|
||
g.moritaniRetentionCard && (
|
||
<div className="notice">
|
||
<p>
|
||
{
|
||
g.players.find(
|
||
(player) => player.id === g.moritaniRetention?.player,
|
||
)?.name
|
||
}{' '}
|
||
has declared {g.moritaniRetentionCard.name} to retain
|
||
after losing the battle. Canceling this power discards all
|
||
of that player’s played cards.
|
||
</p>
|
||
<CardInspector card={g.moritaniRetentionCard} />
|
||
</div>
|
||
)}
|
||
{g.response.kind === 'moritaniPlacement' &&
|
||
g.moritaniPendingPlacement && (
|
||
<p className="notice">
|
||
Your private declaration:{' '}
|
||
{(() => {
|
||
const token = g.moritaniTerror?.tokens.find(
|
||
(t) => t.id === g.moritaniPendingPlacement!.token,
|
||
);
|
||
return token && 'kind' in token
|
||
? TERROR_DEFINITIONS[token.kind].name
|
||
: 'Terror token';
|
||
})()}{' '}
|
||
into {territory(g.moritaniPendingPlacement.territory).name}.
|
||
The token has not moved yet.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'choamInflation' && (
|
||
<p className="muted">
|
||
CHOAM will{' '}
|
||
{g.response.intent === 'double' ? 'double' : 'cancel'} next
|
||
turn’s charity. The following turn uses the opposite effect.
|
||
Karama can stop this placement; the later flip and removal are
|
||
mandatory.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'choamWorthless' && (
|
||
<p className="notice">
|
||
CHOAM declared {g.response.intent}.{' '}
|
||
{g.response.intent === 'Kull Wahad' && (
|
||
<>The attempted card is reserved. Use a different eligible Karama to prevent Kull and resume the original play; allowing Kull retains that attempted card but stops its actor’s Karama activations for this phase. </>
|
||
)}
|
||
{g.response.location &&
|
||
(g.response.intent === 'Jubba Cloak'
|
||
? `Protect CHOAM’s forces in ${territory(splitLocation(g.response.location).territory).name} during this storm movement. `
|
||
: g.response.intent === 'Baliset'
|
||
? `Prevent ${g.players.find((p) => p.id === g.response?.recipient)?.name} from moving into ${territory(splitLocation(g.response.location).territory).name}. `
|
||
: `Selected: ${g.players.find((p) => p.id === g.response?.recipient)?.name}, ${territory(splitLocation(g.response.location).territory).name}, sector ${splitLocation(g.response.location).sector}, ${g.response.elite ? 'elite' : 'ordinary'} force. `)}
|
||
Karama prevents this card’s effect for this phase and leaves
|
||
the card in hand.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'choamBattleIncome' && (
|
||
<p className="notice">
|
||
CHOAM is due {g.response.amount} spice from other players’
|
||
force payments. Karama sends this battle’s income to the bank.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'choamBattleAid' && (
|
||
<p className="notice">
|
||
Karama can prevent CHOAM funding its ally’s forces for this
|
||
battle. The ally will then plan using its own spice.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'choamRevival' && (
|
||
<p className="muted">
|
||
CHOAM revives paid forces for one spice each with no normal
|
||
quantity limit. Karama restores the three-force limit and
|
||
two-spice price for this phase. Tleilaxu permission can still
|
||
allow five forces.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'advisorFlip' && g.response.advisorResume === 'preparation' && (
|
||
<p className="notice">Bene Gesserit declared native advisor preparation before the first shipment. Karama can prevent this territory’s flip. The current phase and its original auction, readiness or movement control continue afterward.</p>
|
||
)}
|
||
{g.response.kind === 'fremenRevivalGrant' && (
|
||
<p className="notice">
|
||
Fremen offered their current ally the ordinary free-revival rate of three.
|
||
Karama can prevent this grant before it takes effect. Recruits uses the
|
||
current valid rate after settlement, with prior returns and payments retained.
|
||
Response timing and prevention of this grant for the current Revival are provisional.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'choamCharity' && (
|
||
<p className="muted">
|
||
CHOAM receives{' '}
|
||
{2 * g.players.length * g.charity.multiplier +
|
||
g.charity.incomeHomeworld * g.charity.multiplier}{' '}
|
||
spice before anyone claims charity
|
||
{g.charity.incomeHomeworld > 0
|
||
? ', including the low-population Homeworld bank bonus'
|
||
: ''}
|
||
. Karama cancels this income and sends this turn’s charity
|
||
payments to the Spice Bank.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'mobileStronghold' && g.mobileRoute && (
|
||
<>
|
||
<p>
|
||
The Ixians declared this route before the storm. Karama
|
||
cancels both the relocation and its spice collection.
|
||
</p>
|
||
<ol>
|
||
{g.mobileRoute.route.map((key) => {
|
||
const loc = splitLocation(key);
|
||
return (
|
||
<li key={key}>
|
||
{territory(loc.territory).name} · sector {loc.sector}
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
<p className="fine">
|
||
Spice collection{' '}
|
||
{g.mobileRoute.collect ? 'requested' : 'declined'}.
|
||
</p>
|
||
</>
|
||
)}
|
||
{g.response.kind === 'fremenMovement' && g.response.location && (
|
||
<p className="notice">
|
||
{g.response.amount} Fremen forces will move to{' '}
|
||
{territory(splitLocation(g.response.location).territory).name}
|
||
, sector {splitLocation(g.response.location).sector}, using
|
||
their two-territory movement advantage. If canceled, the
|
||
forces remain in place and Fremen may choose a one-territory
|
||
move.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'ixMovement' && g.response.location && (
|
||
<p className="notice">
|
||
{g.response.amount} forces, including {g.response.elite}{' '}
|
||
cyborgs, moving to{' '}
|
||
{territory(splitLocation(g.response.location).territory).name}
|
||
, sector {splitLocation(g.response.location).sector}.
|
||
</p>
|
||
)}
|
||
<p className="muted">
|
||
{g.players.find((p) => p.id === g.response?.owner)?.name} is
|
||
using this power. Another faction may play Karama to cancel this
|
||
use.
|
||
</p>
|
||
{g.response.kind === 'eliteStrength' &&
|
||
g.nexusCards &&
|
||
g.players.some((player) => player.id === g.response?.owner && player.faction === 'emperor') && (
|
||
<p className="notice">
|
||
Every seat may respond before the Emperor’s Sardaukar strength is locked.
|
||
A private Emperor Nexus Betrayal may suppress it without moving starred forces.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'voice' &&
|
||
g.nexusCards &&
|
||
g.players.some((player) => player.id === g.response?.owner && player.faction === 'beneGesserit') && (
|
||
<p className="notice">
|
||
Every seat may respond to this Voice before plans are sealed.
|
||
A private Bene Gesserit Nexus Betrayal may prevent it.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'revivalIncome' &&
|
||
g.response.amount !== undefined && (
|
||
<p className="notice">
|
||
{g.response.amount} spice is payable to Tleilaxu after this
|
||
response.
|
||
</p>
|
||
)}
|
||
{g.response.source === 'ambassadorRevival' && (
|
||
<p className="notice">
|
||
The free Ambassador revival is already complete. Canceling
|
||
Tleilaxu’s bank income leaves the revived pieces in play.
|
||
Provisional rule: one free-revival reward per beneficiary
|
||
per turn, shared with ordinary free revival. Ghola cards
|
||
retain their separate income.
|
||
</p>
|
||
)}
|
||
{g.response.source === 'ambassador' && (
|
||
<p className="notice">
|
||
The Richese Ambassador purchase is already paid and its card
|
||
has been drawn. Canceling this benefit leaves that purchase
|
||
unchanged.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'advisorFlip' && g.response.advisorResume === 'planetologistGather' && (
|
||
<p className="notice">
|
||
Provisional mixed Planetologist gather: allow one common fighter
|
||
arrival before either group moves, or cancel the native advisor
|
||
flip. Cancellation keeps both groups and the movement allowance
|
||
unchanged, with flipping blocked for that movement opportunity.
|
||
</p>
|
||
)}
|
||
{g.response.kind === 'nexusGuildCunning' && <p className="notice">
|
||
Allow one second Guild shipment at its normal price, or cancel this extra opportunity.
|
||
The completed first shipment and movement stay unchanged; the Nexus is already spent.
|
||
</p>}
|
||
{g.response.kind === 'nexusFremenCunning' && <p className="notice">
|
||
The Fremen Nexus card was spent for a remote ride after this Nexus. Karama can prevent that ride, but the original worm still resolves.
|
||
</p>}
|
||
{g.response.kind === 'nexusEcazBetrayal' && <p className="notice">
|
||
Ecaz Nexus Betrayal was spent to return Ecaz’s ally’s forces from {territory(g.response.location!).name}.
|
||
Karama can prevent that return; the card stays spent.
|
||
</p>}
|
||
{g.response.intent &&
|
||
g.response.kind !== 'nexusGuildCunning' &&
|
||
g.response.source !== 'ambassador' &&
|
||
g.response.kind !== 'nexusAdvisorFlip' &&
|
||
g.response.kind !== 'nexusSardaukar' &&
|
||
g.response.kind !== 'nexusEcazBetrayal' &&
|
||
g.response.kind !== 'faceDancerReplacement' && (
|
||
<p className="notice">{g.response.intent}</p>
|
||
)}
|
||
{g.nexusTraitors?.pending ? (
|
||
<p className="notice">
|
||
Finish the private Nexus Traitor card return before responding to this power.
|
||
</p>
|
||
) : (
|
||
<>
|
||
{me.hand
|
||
?.filter((c) => g.responseControls?.cancelCards.includes(c.id))
|
||
.map((c) => (
|
||
<Button
|
||
key={c.id}
|
||
className="game-action"
|
||
disabled={busy}
|
||
onClick={() =>
|
||
act({ type: 'card', card: c.id, mode: 'cancel' })
|
||
}
|
||
>
|
||
{c.kind === 'worthless'
|
||
? `Use ${c.name} as Karama`
|
||
: 'Cancel with Karama'}
|
||
</Button>
|
||
))}
|
||
{(g.responseControls?.cancelCards.length ||
|
||
(g.nexusCards && (
|
||
(g.response.kind === 'eliteStrength' &&
|
||
g.players.some((player) => player.id === g.response?.owner && player.faction === 'emperor')) ||
|
||
(g.response.kind === 'voice' &&
|
||
g.players.some((player) => player.id === g.response?.owner && player.faction === 'beneGesserit'))
|
||
))) &&
|
||
!g.responseControls?.hasPassed ? (
|
||
actionButton('Allow this power', { type: 'passResponse' })
|
||
) : (
|
||
<p className="muted">
|
||
{g.responseControls?.hasPassed
|
||
? 'You have allowed this power. Waiting for the response to finish.'
|
||
: 'Waiting for the response to finish.'}
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
) : g.decision ? (
|
||
<>
|
||
<span className="eyebrow">Player decision</span>
|
||
<h2>
|
||
{g.decision.kind === 'ecazBattleLead'
|
||
? g.decision.lowEcaz ? 'Low Ecaz · opponent chooses the lead' : 'Ecaz · choose the combined-army lead'
|
||
: g.decision.kind === 'caladanReinforcement'
|
||
? 'Caladan victory reinforcement'
|
||
: g.decision.kind === 'grummanCollection'
|
||
? 'Grumman Collection'
|
||
: g.decision.kind === 'homeworldOccupiedIncome'
|
||
? 'Occupied Homeworld bank income'
|
||
: g.decision.kind === 'homeworldOccupiedPercentage'
|
||
? 'Occupied Homeworld percentage income'
|
||
: g.decision.kind === 'homeworldOccupiedBonus'
|
||
? 'Occupied Giedi Prime bonus card'
|
||
: g.decision.kind === 'homeworldTupileCleanup'
|
||
? 'Occupied Tupile · return to the normal hand limit'
|
||
: g.decision.kind === 'leaderSkillVisibility'
|
||
? 'Position your skilled leader'
|
||
: g.decision.kind === 'mentatQuestion'
|
||
? g.decision.stage === 'name' ? 'Mentat · name a weapon' : 'Mentat · privately show a card'
|
||
: g.decision.kind === 'bureaucratPayment'
|
||
? 'Bureaucrat · redirect a payment'
|
||
: g.decision.kind === 'leaderSkillRevival'
|
||
? 'Choose a skill for the revived leader'
|
||
: g.decision.kind === 'rihani'
|
||
? g.decision.stage === 'offer' ? 'Rihani Decipherer — optional draw' : 'Choose your Traitor exchange'
|
||
: g.decision.kind === 'sukRescue'
|
||
? 'Save your battle casualties'
|
||
: g.decision.kind === 'smugglerCollection'
|
||
? 'Choose Smuggler collection sectors'
|
||
: g.decision.kind === 'sandmasterVictorySpice'
|
||
? 'Choose Sandmaster victory spice sector'
|
||
: g.decision.kind === 'jacurutuDial'
|
||
? 'Identify your Jacurutu physical dial'
|
||
: g.decision.kind === 'harassWithdraw'
|
||
? 'Choose undialed forces to return'
|
||
: g.decision.kind === 'diplomatRetreat'
|
||
? 'Retreat undialed forces'
|
||
: g.decision.kind === 'diplomatDefense'
|
||
? 'Copy an opposing defense'
|
||
: g.decision.kind === 'homeworldRevivalDeployment'
|
||
? 'Revival deployment'
|
||
: g.decision.kind === 'ecazSpice'
|
||
? 'Shared spice collection'
|
||
: g.decision.kind === 'stoneBurner'
|
||
? g.decision.copiedFrom ? 'Mirror Weapon copying Stone Burner' : 'Stone Burner'
|
||
: g.decision.kind === 'nullentropy'
|
||
? 'Private Nullentropy Box search'
|
||
: g.decision.kind === 'richeseAllyOpportunity'
|
||
? 'Offer allied No-Field shipment'
|
||
: g.decision.kind === 'richeseAllyShipment'
|
||
? 'Allied No-Field shipment'
|
||
: [
|
||
'richeseBlackMarket',
|
||
'richeseDeclaration',
|
||
'richeseCache',
|
||
'richeseCacheTerms',
|
||
'richeseUnbid',
|
||
].includes(g.decision.kind)
|
||
? 'Richese auction decision'
|
||
: g.decision.kind === 'ecazAmbassador'
|
||
? 'Ecaz · Ambassador entry effect'
|
||
: g.decision.kind === 'ecazPlacement'
|
||
? 'Ecaz · Ambassador placement'
|
||
: g.decision.kind === 'moritaniAssassinate'
|
||
? 'Moritani · Assassinate Leaders'
|
||
: g.decision.kind === 'moritaniRetention'
|
||
? g.decision.source === 'nexus' ? 'Moritani Nexus · after-loss choice' : 'Moritani · allied card retention'
|
||
: g.decision.kind === 'moritaniTerror'
|
||
? 'Moritani · Terror entry reaction'
|
||
: g.decision.kind === 'moritaniExtortion'
|
||
? 'Moritani · Extortion payment'
|
||
: g.decision.kind === 'moritaniSetup'
|
||
? 'Moritani · final deployment'
|
||
: g.decision.kind === 'moritaniPlacement'
|
||
? 'Moritani · hidden Terror placement'
|
||
: g.decision.kind === 'choamStorm'
|
||
? 'CHOAM storm protection'
|
||
: g.decision.kind === 'choamMovement'
|
||
? 'CHOAM movement response'
|
||
: g.decision.kind === 'choamMentat'
|
||
? 'CHOAM’s final Mentat opportunity'
|
||
: g.decision.kind ===
|
||
'choamFreeRevival'
|
||
? 'CHOAM free revival response'
|
||
: g.decision.kind ===
|
||
'choamBattleFunding'
|
||
? 'CHOAM battle funding'
|
||
: g.decision.kind ===
|
||
'choamMarket'
|
||
? 'CHOAM · end of phase'
|
||
: g.decision.kind ===
|
||
'choamTradeReply' ||
|
||
g.decision.kind ===
|
||
'choamTradeConfirm'
|
||
? 'Allied card exchange'
|
||
: g.decision.kind ===
|
||
'ixSetup'
|
||
? 'Choose your starting technology'
|
||
: g.decision.kind ===
|
||
'ixAuction'
|
||
? 'Prepare the auction'
|
||
: g.decision.kind ===
|
||
'ixTechnology'
|
||
? 'Substitute an auction card'
|
||
: g.decision
|
||
.kind ===
|
||
'ixAllyCard'
|
||
? 'Keep or replace your purchase'
|
||
: g.decision
|
||
.kind ===
|
||
'mobileStronghold'
|
||
? g.decision
|
||
.placement
|
||
? 'Place your stronghold'
|
||
: 'Relocate before the storm'
|
||
: g.decision
|
||
.kind ===
|
||
'ixSubstitution'
|
||
? 'Retain lost cyborgs'
|
||
: g.decision
|
||
.kind ===
|
||
'revivalStop'
|
||
? 'A revival awaits clearance'
|
||
: g.decision
|
||
.kind ===
|
||
'faceDance'
|
||
? 'A hidden allegiance'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'poisonTooth'
|
||
? 'Activate Poison Tooth?'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'techToken'
|
||
? 'Claim a tech token'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'fullPlanOffer'
|
||
? 'See the entire battle plan'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'guildShipment' || g.decision.kind === 'homeworldShipmentGuild'
|
||
? 'A shipment awaits clearance'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'handExchange'
|
||
? 'Choose cards to return'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'captureOffer' ||
|
||
g
|
||
.decision
|
||
.kind ===
|
||
'capturedLeader'
|
||
? 'A captive leader'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'guildTiming'
|
||
? 'Choose when to act'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'stormLosses'
|
||
? 'Survive the storm'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'wormPlacement'
|
||
? 'Place an additional worm'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'battleLosses'
|
||
? 'Choose your casualties'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'auctionPayment'
|
||
? 'Settle your bid'
|
||
: [
|
||
'advisor',
|
||
'intrusion',
|
||
'advisorBattle',
|
||
].includes(
|
||
g
|
||
.decision
|
||
.kind,
|
||
)
|
||
? 'Advisors & fighters'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'wormProtection' || g.decision.kind === 'nexusFremenProtection'
|
||
? 'Protection of Shai-Hulud'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'discoveryEntry'
|
||
? 'Enter a Discovery location'
|
||
: g.decision.kind === 'ecologicalStorm'
|
||
? 'Ecological Testing Station'
|
||
: g.decision.kind === 'nexusFremenCunningOffer'
|
||
? 'Empty worm: Fremen Cunning'
|
||
: g.decision.kind === 'nexusFremenCunningRide'
|
||
? 'Fremen Cunning remote ride'
|
||
: g.decision.kind === 'nexusEmperorAmbassadorPurchase'
|
||
? 'Richese Ambassador purchase'
|
||
: g.decision.kind === 'nexusEmperorSellerPurchase'
|
||
? 'Richese lot payment'
|
||
: g.decision.kind === 'nexusEmperorPayment'
|
||
? 'Emperor allied-purchase funding'
|
||
: g
|
||
.decision
|
||
.kind ===
|
||
'wormRide'
|
||
? 'Ride the sandworm'
|
||
: 'Spoils of battle'}
|
||
</h2>
|
||
{g.decision.kind === 'capturedLeader' &&
|
||
(g.decision.controller ?? g.decision.owner) === me.id && (
|
||
<p className="notice">
|
||
Your leader {leaderName(g.decision.leader)} was captured.
|
||
</p>
|
||
)}
|
||
{g.decision.kind === 'choamMovement' &&
|
||
g.decision.player !== me.id && (
|
||
<p className="notice">
|
||
{
|
||
g.players.find(
|
||
(p) =>
|
||
p.id ===
|
||
(g.decision?.kind === 'choamMovement'
|
||
? g.decision.mover
|
||
: ''),
|
||
)?.name
|
||
}{' '}
|
||
has declared a move of {g.decision.amount} forces into{' '}
|
||
{combatName(g.decision.territory)}, sector{' '}
|
||
{g.decision.sector}. CHOAM must allow the move or respond
|
||
with Baliset; no forces have moved yet.
|
||
</p>
|
||
)}
|
||
{g.decision.kind === 'choamStorm' &&
|
||
g.decision.player !== me.id && (
|
||
<p className="notice">
|
||
The storm is waiting for CHOAM to choose protection.
|
||
Threatened forces:{' '}
|
||
{g.decision.territories
|
||
.map(
|
||
(t) => `${territory(t.territory).name} (${t.amount})`,
|
||
)
|
||
.join(', ')}
|
||
. No storm casualties have been applied.
|
||
</p>
|
||
)}
|
||
{g.decision.player !== me.id ? (
|
||
<p className="muted">
|
||
Waiting for{' '}
|
||
{g.players.find((p) => p.id === g.decision?.player)?.name} to
|
||
complete this decision.
|
||
</p>
|
||
) : g.decision.kind === 'nexusIxianReplacement' ? (
|
||
<NexusIxianReplacement game={g} act={act} busy={reactionBusy} />
|
||
) : g.decision.kind === 'nullentropy' ? (
|
||
<NullentropySearch game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'richeseAllyOpportunity' ? (
|
||
<>
|
||
<p className="fine">
|
||
Your ally’s shipment is waiting for your optional No-Field
|
||
offer. Choose the private token, destination and payer in
|
||
the controls above, or let your ally ship normally.
|
||
</p>
|
||
{actionButton('Let ally ship normally', {
|
||
type: 'decision',
|
||
decline: true,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'richeseAllyShipment' ? (
|
||
<RicheseAlliedNoFieldDecision
|
||
key={g.richeseNoField?.allyOffer?.event}
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
/>
|
||
) : g.decision.kind === 'richeseBlackMarket' ||
|
||
g.decision.kind === 'richeseDeclaration' ||
|
||
g.decision.kind === 'richeseCache' ||
|
||
g.decision.kind === 'richeseCacheTerms' ||
|
||
g.decision.kind === 'richeseUnbid' ? (
|
||
<RicheseAuctionDecision
|
||
key={`${g.richeseBidding?.event}-${g.decision.kind}-${g.me}`}
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
/>
|
||
) : g.decision.kind === 'moritaniAssassinate' ? (
|
||
<MoritaniAssassinate key={g.decision.event} game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'moritaniSpecialKarama' ? (
|
||
<MoritaniSpecialKarama quote={g.moritaniSpecialKarama ?? null} act={act} busy={busy}
|
||
declineAction={{ type: 'decision', event: g.decision.event, decline: true }} />
|
||
) : g.decision.kind === 'moritaniRetention' ? (
|
||
<MoritaniRetention game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'moritaniTerror' ? (
|
||
<MoritaniEntry game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'moritaniSetup' ? (
|
||
<>
|
||
<p>
|
||
Place six starting forces in one unoccupied territory.
|
||
Select a territory and sector on the map. Fourteen forces
|
||
remain in reserves.
|
||
</p>
|
||
<p className="muted">
|
||
Advisors also occupy a territory. The Hidden Mobile
|
||
Stronghold cannot be chosen.
|
||
</p>
|
||
{actionButton(
|
||
'Place six starting forces',
|
||
{ type: 'decision', territory: selected, sector },
|
||
!TERRITORIES.some(
|
||
(t) => t.id === selected && t.sectors.includes(sector),
|
||
) ||
|
||
g.players.some((p) =>
|
||
Object.entries(p.forces).some(
|
||
([key, n]) =>
|
||
n > 0 && splitLocation(key).territory === selected,
|
||
),
|
||
),
|
||
)}
|
||
</>
|
||
) : g.decision.kind === 'ecazAmbassador' ? (
|
||
<EcazEntry
|
||
key={`${g.ambassadorEntry?.event}-${g.ambassadorEntry?.stage}-${g.me}`}
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
/>
|
||
) : g.decision.kind === 'mentatQuestion' ? (
|
||
<MentatQuestion key={`${g.decision.event}-${g.decision.stage}-${g.me}`} game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'bureaucratPayment' ? (
|
||
<BureaucratPayment game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'leaderSkillVisibility' || g.decision.kind === 'leaderSkillRevival' ? (
|
||
<p className="muted">Use the Leader Skills controls above to make your choice.</p>
|
||
) : g.decision.kind === 'homeworldRevivalDeployment' ? (
|
||
<HomeworldRevivalDeployment game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'caladanReinforcement' ? (
|
||
<CaladanReinforcement game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'grummanCollection' ? (
|
||
<GrummanCollection game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'homeworldOccupiedIncome' ? (
|
||
<HomeworldOccupiedIncome offer={g.homeworldOccupiedIncome} act={act} busy={busy} />
|
||
) : g.decision.kind === 'homeworldOccupiedPercentage' ? (
|
||
<HomeworldOccupiedPercentage game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'homeworldOccupiedBonus' ? (
|
||
<HomeworldOccupiedBonus game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'homeworldTupileCleanup' ? (
|
||
<HomeworldTupileCleanup game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'ecazSpice' ? (
|
||
g.ecazSpice?.allocation ? (
|
||
<EcazSpice
|
||
key={`${g.ecazSpice.event}-${g.ecazSpice.allocation.index}-${g.ecazSpice.allocation.player}`}
|
||
event={g.ecazSpice.event}
|
||
allocation={g.ecazSpice.allocation}
|
||
me={me.id}
|
||
players={g.players}
|
||
send={send}
|
||
busy={busy}
|
||
/>
|
||
) : (
|
||
<p>Waiting for the current spice allocation.</p>
|
||
)
|
||
) : g.decision.kind === 'ecazPlacement' ? (
|
||
<EcazPlacement game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'moritaniPlacement' ? (
|
||
<MoritaniPlacement game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'moritaniExtortion' ? (
|
||
<MoritaniExtortion game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'choamStorm' ? (
|
||
<ChoamStorm game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'choamMovement' ? (
|
||
<ChoamBaliset game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'choamMentat' ? (
|
||
<ChoamGamont game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'choamFreeRevival' ? (
|
||
<ChoamWorthless game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'choamBattleFunding' ? (
|
||
<ChoamBattleFunding game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'choamMarket' ||
|
||
g.decision.kind === 'choamTradeReply' ||
|
||
g.decision.kind === 'choamTradeConfirm' ? (
|
||
<ChoamMarket game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'ixSetup' ||
|
||
g.decision.kind === 'ixAuction' ||
|
||
g.decision.kind === 'ixTechnology' ||
|
||
g.decision.kind === 'ixAllyCard' ? (
|
||
<IxTechnology
|
||
key={`${g.turn}-${g.decision.kind}`}
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
/>
|
||
) : g.decision.kind === 'mobileStronghold' ? (
|
||
<MobileStronghold
|
||
key={`${g.turn}-${g.mobileStronghold?.location}`}
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
/>
|
||
) : g.decision.kind === 'ixSubstitution' ? (
|
||
<IxSubstitution game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'revivalStop' ? (
|
||
<>
|
||
<p>
|
||
{
|
||
g.players.find(
|
||
(p) =>
|
||
g.decision?.kind === 'revivalStop' &&
|
||
p.id === g.decision.recipient,
|
||
)?.name
|
||
}{' '}
|
||
declared a{' '}
|
||
{g.decision.revival === 'forces' ? 'force' : 'leader'}{' '}
|
||
revival. Allow it or use your once-per-game special Karama
|
||
to prevent their further force and leader revivals during
|
||
this Revival Phase, including Ghola.
|
||
</p>
|
||
{g.decision.source === 'nexusEmperor' && (
|
||
<p className="notice">
|
||
Provisional policy: this pending Emperor Nexus extra return also
|
||
requires your clearance. Prevention returns no counters or revival
|
||
income; both the Nexus and your special Karama remain spent.
|
||
</p>
|
||
)}
|
||
<TleilaxuSpecialKarama game={g} act={act} busy={busy} />
|
||
{actionButton('Allow this revival', {
|
||
type: 'decision',
|
||
decline: true,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'nexusChoamInspection' ? (
|
||
<NexusChoamInspectionDecision game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'choamAudit' ||
|
||
g.decision.kind === 'choamAuditPayment' ? (
|
||
<AuditorDecision game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'ecazBattleLead' ? (
|
||
<EcazOccupyLeadChoice game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'strongholdCopy' ? (
|
||
<StrongholdCopyChoice
|
||
event={g.decision.event}
|
||
choices={g.decision.choices}
|
||
act={act}
|
||
busy={busy}
|
||
/>
|
||
) : g.decision.kind === 'stoneBurner' ? (
|
||
<>
|
||
<h3>Choose {g.decision.copiedFrom ? 'Mirror Weapon’s copied Stone Burner' : 'Stone Burner'} effect</h3>
|
||
<p className="muted">
|
||
Choose whether both leaders die or surviving leaders
|
||
contribute no strength. Both choices compare undialed
|
||
physical force tokens; the choice does not change that
|
||
comparison. Other weapon effects and traitor precedence
|
||
still apply.
|
||
</p>
|
||
{g.decision.copiedFrom && <p className="fine">Your physical Mirror Weapon copied the opposing Stone Burner. Your choice is independent of its owner’s; winning retains Mirror as Mirror.</p>}
|
||
<p className="fine">
|
||
Provisional Harass & Withdraw timing: compare undialed
|
||
forces remaining after a successful withdrawal. An opposing
|
||
successful Traitor cancels that withdrawal.
|
||
</p>
|
||
{Object.values(g.battle?.plans ?? {}).some(plan => plan && usesNativeBattleSpecial(plan, 'ecaz-reinforcements')) && (
|
||
<p className="fine">
|
||
Reinforcements still pays its original three-reserve cost.
|
||
Its +2 normal-score bonus does not add undialed physical
|
||
tokens or change Stone Burner’s comparison.
|
||
</p>
|
||
)}
|
||
{g.expansions.includes('ix') && <p className="fine">
|
||
Provisional original Stone/Tooth order: Stone chooses first,
|
||
then uncopied Tooth. A Mirror copy still chooses before its
|
||
original, as printed. Traitor decisions follow all choices.
|
||
</p>}
|
||
<a href="/rules?topic=stone-burner">
|
||
Read Stone Burner rules
|
||
</a>
|
||
{actionButton('Kill both leaders', {
|
||
type: 'decision',
|
||
event: g.decision.event,
|
||
mode: 'kill',
|
||
})}
|
||
{actionButton('Ignore surviving leader strength', {
|
||
type: 'decision',
|
||
event: g.decision.event,
|
||
mode: 'ignore',
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'smugglerCollection' ? (
|
||
<SmugglerBattleCollection key={g.decision.event} offer={g.decision} act={act} busy={busy} />
|
||
) : g.decision.kind === 'sandmasterVictorySpice' ? (
|
||
<SandmasterVictorySpice offer={g.decision} act={act} busy={busy} />
|
||
) : g.decision.kind === 'jacurutuDial' && g.jacurutuDial ? (
|
||
<JacurutuDial offer={g.jacurutuDial}
|
||
onChoose={allocation => act({type:'decision',event:g.jacurutuDial!.event,allocation})} busy={busy} />
|
||
) : g.decision.kind === 'harassWithdraw' ? (
|
||
<>
|
||
<HarassWithdrawChoice key={g.decision.event} game={g} act={act} busy={busy} />
|
||
</>
|
||
) : g.decision.kind === 'diplomatRetreat' ? (
|
||
<DiplomatRetreat key={g.decision.event} game={g} act={act} busy={busy} />
|
||
) : g.decision.kind === 'diplomatDefense' ? (
|
||
<>
|
||
<DiplomatDefense game={g} act={act} busy={busy} />
|
||
</>
|
||
) : g.decision.kind === 'poisonTooth' ? (
|
||
<>
|
||
{g.decision.copiedFrom && <p className="fine">Your physical Mirror Weapon copied the opposing Poison Tooth. Choose its activation independently; a winning Mirror remains Mirror.</p>}
|
||
<p className="muted">
|
||
Choose after reviewing both plans. Activating attacks both
|
||
leaders, including yours. Chemistry as a defense protects;
|
||
Snoopers do not. Traitor decisions follow.
|
||
</p>
|
||
{g.expansions.includes('ix') && <p className="fine">
|
||
A Mirror copy chooses before the original Tooth.
|
||
With uncopied Stone and Tooth, this prototype provisionally
|
||
chooses Stone first. Both answers precede Traitor decisions.
|
||
</p>}
|
||
{actionButton('Activate Poison Tooth', {
|
||
type: 'decision',
|
||
event: g.decision.event,
|
||
activate: true,
|
||
})}
|
||
{actionButton('Leave it unused', {
|
||
type: 'decision',
|
||
event: g.decision.event,
|
||
activate: false,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'faceDance' ? (
|
||
<FaceDanceDecision
|
||
key={`${g.turn}-${g.decision.territory}-${g.decision.winner}-${g.decision.leader}`}
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
/>
|
||
) : g.decision.kind === 'techToken' ? (
|
||
<>
|
||
<p className="muted">
|
||
Take one tech token from the defeated faction. Holding all
|
||
three counts as one stronghold for victory.
|
||
</p>
|
||
<HelpTip topic="techTokens" />
|
||
{g.decision.choices.map((token) => (
|
||
<div key={token}>
|
||
{actionButton(
|
||
`Take ${TECH_TOKENS.find((t) => t.id === token)!.name}`,
|
||
{ type: 'decision', token },
|
||
)}
|
||
</div>
|
||
))}
|
||
</>
|
||
) : g.decision.kind === 'fullPlanOffer' ? (
|
||
<>
|
||
<AtreidesFullPlan game={g} act={act} busy={busy} />
|
||
{actionButton('Keep special power for later', {
|
||
type: 'decision',
|
||
decline: true,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'fullPlanRead' ? (
|
||
<p className="muted">
|
||
Restoring the saved battle continuation. Your inspection
|
||
remains available above; no confirmation is needed.
|
||
</p>
|
||
) : g.decision.kind === 'homeworldShipmentGuild' ? (
|
||
<>
|
||
<p className="muted">
|
||
{g.players.find((p) => p.id === (g.decision?.kind === 'homeworldShipmentGuild' ? g.decision.shipper : ''))?.name} declares {g.decision.amount} forces to {combatName(g.decision.destination)}. Allow this shipment or spend your once-per-game special Karama to stop it.
|
||
</p>
|
||
<HelpTip topic="guildShipment" />
|
||
{actionButton('Allow Homeworld shipment', { type: 'decision', event: g.decision.event, allow: true })}
|
||
{me.hand?.filter(canActivateKarama).map((c) => (
|
||
<div key={c.id}>{actionButton('Spend Karama · stop shipment', { type: 'card', mode: 'special', card: c.id },
|
||
!!g.guildShipmentStopPromiseBlock, g.guildShipmentStopPromiseBlock ? 'guild-stop-promise-guidance' : undefined)}</div>
|
||
))}
|
||
{g.guildShipmentStopPromiseBlock && <p id="guild-stop-promise-guidance" role="status">{g.guildShipmentStopPromiseBlock}</p>}
|
||
</>
|
||
) : g.decision.kind === 'guildShipment' ? (
|
||
<>
|
||
<p className="muted">
|
||
{
|
||
g.players.find(
|
||
(p) =>
|
||
p.id ===
|
||
(g.decision?.kind === 'guildShipment'
|
||
? g.decision.shipper
|
||
: ''),
|
||
)?.name
|
||
}{' '}
|
||
declares {g.decision.amount} forces to{' '}
|
||
{combatName(g.decision.territory)}, sector{' '}
|
||
{g.decision.sector}. Allow this shipment or spend your
|
||
once-per-game special Karama to stop it.
|
||
</p>
|
||
<HelpTip topic="guildShipment" />
|
||
{actionButton('Allow shipment', {
|
||
type: 'decision',
|
||
allow: true,
|
||
})}
|
||
{me.hand
|
||
?.filter(canActivateKarama)
|
||
.map((c) => (
|
||
<div key={c.id}>
|
||
{actionButton('Spend Karama · stop shipment', {
|
||
type: 'card',
|
||
mode: 'special',
|
||
card: c.id,
|
||
}, !!g.guildShipmentStopPromiseBlock,
|
||
g.guildShipmentStopPromiseBlock ? 'guild-stop-promise-guidance' : undefined)}
|
||
</div>
|
||
))}
|
||
{g.guildShipmentStopPromiseBlock && <p id="guild-stop-promise-guidance" role="status">{g.guildShipmentStopPromiseBlock}</p>}
|
||
</>
|
||
) : g.decision.kind === 'handExchange' ? (
|
||
<>
|
||
<p className="muted">
|
||
Inspect your combined hand, then return exactly{' '}
|
||
{g.decision.count} cards to{' '}
|
||
{
|
||
g.players.find(
|
||
(p) =>
|
||
p.id ===
|
||
(g.decision?.kind === 'handExchange'
|
||
? g.decision.target
|
||
: ''),
|
||
)?.name
|
||
}
|
||
. You may return cards you just took.
|
||
</p>
|
||
<HelpTip topic="handExchange" />
|
||
{me.hand?.map((c) => (
|
||
<label className="checkbox-row" key={c.id}>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!exchangeCards[c.id]}
|
||
onChange={(e) =>
|
||
setExchangeCards({
|
||
...exchangeCards,
|
||
[c.id]: e.target.checked,
|
||
})
|
||
}
|
||
/>
|
||
{c.name}
|
||
</label>
|
||
))}
|
||
{actionButton(
|
||
'Return selected cards',
|
||
{
|
||
type: 'decision',
|
||
returnCards:
|
||
me.hand
|
||
?.filter((c) => exchangeCards[c.id])
|
||
.map((c) => c.id) ?? [],
|
||
},
|
||
me.hand?.filter((c) => exchangeCards[c.id]).length !==
|
||
g.decision.count,
|
||
)}
|
||
</>
|
||
) : g.decision.kind === 'intrusion' ? (
|
||
<>
|
||
<p className="muted">
|
||
Another faction entered {combatName(g.decision.territory)}.
|
||
You may turn every fighter there into an advisor. A Karama
|
||
response resolves before the flip.
|
||
</p>
|
||
{actionButton('Become advisors', {
|
||
type: 'decision',
|
||
accept: true,
|
||
})}
|
||
{actionButton('Remain fighters', {
|
||
type: 'decision',
|
||
accept: false,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'advisorBattle' ? (
|
||
<>
|
||
<p className="muted">
|
||
Before anyone ships, choose where your advisors will fight.
|
||
Each territory has its own Karama response.
|
||
</p>
|
||
{g.decision.territories.map((t) => (
|
||
<div key={t}>
|
||
{actionButton(`Prepare in ${territory(t).name}`, {
|
||
type: 'decision',
|
||
accept: true,
|
||
territory: t,
|
||
})}
|
||
</div>
|
||
))}
|
||
{actionButton('Keep remaining advisors peaceful', {
|
||
type: 'decision',
|
||
accept: false,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'captureOffer' ? (
|
||
<>
|
||
<p className="muted">
|
||
You may capture a random eligible surviving leader from the
|
||
defeated faction. Inspect the captive before choosing
|
||
whether to retain or execute them.
|
||
</p>
|
||
{actionButton('Capture a leader', {
|
||
type: 'decision',
|
||
accept: true,
|
||
})}
|
||
{actionButton('Decline capture', {
|
||
type: 'decision',
|
||
accept: false,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'capturedLeader' ? (
|
||
<>
|
||
<p className="notice">
|
||
{leaderName(g.decision.leader)} · strength{' '}
|
||
{
|
||
(g.decision.leader === 'duke-vidal'
|
||
? g.dukeVidal?.leader
|
||
: g.players.flatMap((seat) => seat.leaders).find(
|
||
(leader) => leader.id === (g.decision?.kind === 'capturedLeader' ? g.decision.leader : ''),
|
||
))?.strength
|
||
}
|
||
</p>
|
||
<p className="muted">
|
||
Retain this leader for one battle, or execute them for two
|
||
spice. A survivor returns to their faction after use. If all
|
||
your own leaders are dead, a retained captive returns
|
||
immediately.
|
||
</p>
|
||
{g.decision.leader === 'duke-vidal' && (
|
||
<p className="fine">
|
||
Duke remains the one shared Ecaz disc. A surviving captive
|
||
battle use sets him aside; execution sends him to Ecaz’s
|
||
Tanks. Only Ecaz may revive him. The provisional captured
|
||
return policy preserves unused, unexpired prior custody
|
||
on an early release. Ecaz Nexus reclaim ends captivity.
|
||
</p>
|
||
)}
|
||
{actionButton('Retain captive', {
|
||
type: 'decision',
|
||
mode: 'keep',
|
||
})}
|
||
{actionButton('Execute · gain 2 spice', {
|
||
type: 'decision',
|
||
mode: 'execute',
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'guildTiming' ? (
|
||
<>
|
||
<p className="muted">
|
||
Take your complete shipment and movement now, or let{' '}
|
||
{
|
||
g.players.find(
|
||
(p) =>
|
||
p.id ===
|
||
(g.decision?.kind === 'guildTiming'
|
||
? g.decision.following
|
||
: ''),
|
||
)?.name
|
||
}{' '}
|
||
act first. You can decide again before the next player
|
||
starts.
|
||
</p>
|
||
{actionButton('Ship and move now', {
|
||
type: 'decision',
|
||
take: true,
|
||
})}
|
||
{actionButton('Wait', { type: 'decision', take: false })}
|
||
</>
|
||
) : g.decision.kind === 'stormLosses' ? (
|
||
<>
|
||
<p className="muted">
|
||
Lose {g.decision.amount} forces in{' '}
|
||
{territory(splitLocation(g.decision.key).territory).name},
|
||
sector {splitLocation(g.decision.key).sector}. Choose how
|
||
many Fedaykin are among the losses.
|
||
</p>
|
||
{Array.from(
|
||
{
|
||
length: stormLosses!.maxElite - stormLosses!.minElite + 1,
|
||
},
|
||
(_, i) => stormLosses!.minElite + i,
|
||
).map((elite) => (
|
||
<div key={elite}>
|
||
{actionButton(
|
||
`Lose ${elite} Fedaykin + ${stormLosses!.amount - elite} ordinary`,
|
||
{ type: 'decision', elite },
|
||
)}
|
||
</div>
|
||
))}
|
||
</>
|
||
) : g.decision.kind === 'wormPlacement' ? (
|
||
<>
|
||
<p className="muted">
|
||
An additional worm appeared in this spice blow. Choose a
|
||
sand territory for its attack.
|
||
</p>
|
||
<label>
|
||
Sand territory
|
||
<select
|
||
value={
|
||
territory(selected).type === 'sand' ? selected : ''
|
||
}
|
||
onChange={(e) => choose(e.target.value)}
|
||
>
|
||
<option value="" disabled>
|
||
Choose sand territory
|
||
</option>
|
||
{TERRITORIES.filter((t) => t.type === 'sand').map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{actionButton(
|
||
'Place worm',
|
||
{ type: 'decision', accept: true, territory: selected },
|
||
territory(selected).type !== 'sand',
|
||
)}
|
||
{actionButton('Do not place this worm', {
|
||
type: 'decision',
|
||
accept: false,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'homeworldDefense' ? (
|
||
<>
|
||
<h3>Late poison defense</h3>
|
||
<p className="muted">
|
||
The Traitor window is complete. You may add Portable Snooper
|
||
before this Homeworld battle resolves. It protects against
|
||
ordinary poison, not Poison Tooth.
|
||
</p>
|
||
{me.hand
|
||
?.filter(
|
||
(card) =>
|
||
richeseCardDefinition(card)?.card.effect ===
|
||
'portableSnooper',
|
||
)
|
||
.map((card) => (
|
||
<div key={card.id}>
|
||
<CardRules card={card} />
|
||
<CardInspector card={card} />
|
||
</div>
|
||
))}
|
||
{actionButton('Use Portable Snooper', {
|
||
type: 'decision',
|
||
event: g.decision.event,
|
||
use: true,
|
||
})}
|
||
{actionButton('Keep current defense', {
|
||
type: 'decision',
|
||
event: g.decision.event,
|
||
use: false,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'homeworldExplosion' ? (
|
||
<>
|
||
<h3>Native explosion casualties</h3>
|
||
<p className="muted">
|
||
The Lasgun–shield explosion limits your native losses at{' '}
|
||
{combatName(g.decision.territory)} to the Homeworld’s
|
||
printed battle strength. Choose the physical counters sent
|
||
to the Tanks; your other native forces remain here.
|
||
</p>
|
||
<p className="fine">
|
||
Available: {g.decision.pool.normal}{' '}
|
||
{me.faction === 'ixians' ? 'Suboid' : 'ordinary'} and{' '}
|
||
{g.decision.pool.elite} {specialForceName(me.faction)}{' '}
|
||
counters.
|
||
</p>
|
||
{g.decision.options.map((option, choice) => (
|
||
<div key={choice}>
|
||
{actionButton(
|
||
`Lose ${option.normal} ${me.faction === 'ixians' ? 'Suboid' : 'ordinary'} + ${option.elite} ${specialForceName(me.faction)} counters`,
|
||
{
|
||
type: 'decision',
|
||
event:
|
||
g.decision?.kind === 'homeworldExplosion'
|
||
? g.decision.event
|
||
: undefined,
|
||
choice,
|
||
},
|
||
)}
|
||
</div>
|
||
))}
|
||
</>
|
||
) : g.decision.kind === 'sukRescue' ? (
|
||
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
) : g.decision.kind === 'rihani' ? (
|
||
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
) : g.decision.kind === 'battleLosses' ? (
|
||
<>
|
||
<p className="muted">
|
||
At {combatName(g.decision.territory)}, choose forces to lose
|
||
that match your revealed strength and spice support.
|
||
</p>
|
||
{g.decision.options.map((option, choice) => (
|
||
<div key={choice}>
|
||
{actionButton(
|
||
`Lose ${option.normal} ${me.faction === 'ixians' ? 'suboids' : 'ordinary'} + ${option.elite} ${me.faction === 'ixians' ? 'cyborgs' : 'elite'}`,
|
||
{ type: 'decision', choice },
|
||
)}
|
||
</div>
|
||
))}
|
||
</>
|
||
) : g.decision.kind === 'nexusEmperorPayment' ? (
|
||
<NexusEmperorPayment game={g} send={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
) : g.decision.kind === 'nexusEmperorSellerPurchase' ? (
|
||
<NexusEmperorSellerPurchase game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
) : g.decision.kind === 'nexusEmperorAmbassadorPurchase' ? (
|
||
<NexusEmperorAmbassadorPurchase game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
) : g.decision.kind === 'auctionPayment' ? (
|
||
<>
|
||
<p className="muted">
|
||
You won the auction for {g.auction?.bid} spice. Pay the bid
|
||
or spend a Karama to take the card without paying spice.
|
||
</p>
|
||
{actionButton(
|
||
'Pay with spice',
|
||
{ type: 'decision', karama: false },
|
||
(g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available + (g.auction?.coalitionPayment ?? 0),
|
||
)}
|
||
{me.hand
|
||
?.filter((card) => canSpendKarama(card, 'auction-free-purchase'))
|
||
.map((c) => (
|
||
<div key={c.id}>
|
||
{actionButton(
|
||
`Spend ${c.name}${c.kind === 'worthless' ? ' as Karama' : ''}`,
|
||
{ type: 'decision', karama: true, card: c.id },
|
||
)}
|
||
</div>
|
||
))}
|
||
</>
|
||
) : g.decision.kind === 'advisor' ? (
|
||
<>
|
||
<p className="muted">
|
||
{g.decision.caladanArrival
|
||
? 'Moritani’s Sneak Attack group has arrived from off-planet. You may send'
|
||
: g.decision.revivalEvent
|
||
? 'Tleilaxu’s free-revived group has arrived from off-planet. You may send'
|
||
: 'Another faction has shipped from off-planet. You may send'}
|
||
{' '}
|
||
up to {g.homeworldMobility?.advisorSinkMaximum ?? 1} reserve forces to the Polar Sink for free.
|
||
</p>
|
||
{g.advanced&&<p className="notice">Retained fighters outside the Polar Sink require Polar Sink or decline, unless Intrusion already changed them to advisors. Polar matching and an alone conversion have separate responses.</p>}
|
||
{(g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices) ? (
|
||
(g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)!.choices.map((choice) => {
|
||
const reasonId = `guild-advisor-${choice.accompany}-${choice.territory}-${choice.sector}-${choice.amount}`;
|
||
return (
|
||
<div key={reasonId}>
|
||
{actionButton(
|
||
choice.accompany
|
||
? `Accompany · ${territory(choice.territory).name} · sector ${choice.sector}`
|
||
: `Send ${choice.amount} to Polar Sink`,
|
||
{
|
||
type: 'decision',
|
||
accept: true,
|
||
accompany: choice.accompany,
|
||
territory: choice.territory,
|
||
sector: choice.sector,
|
||
amount: choice.amount,
|
||
},
|
||
!!choice.blocked,
|
||
choice.blocked ? reasonId : undefined,
|
||
)}
|
||
{choice.blocked && (
|
||
<p id={reasonId} className="muted">
|
||
{choice.blocked}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})
|
||
) : (
|
||
<>
|
||
{g.advanced &&
|
||
g.decision.destination &&
|
||
territory(
|
||
splitLocation(g.decision.destination).territory,
|
||
).sectors.map((s) => (
|
||
<div key={s}>
|
||
{actionButton(
|
||
`Accompany · sector ${s}`,
|
||
{
|
||
type: 'decision',
|
||
accept: true,
|
||
accompany: true,
|
||
sector: s,
|
||
},
|
||
s !== 0 && s === g.storm,
|
||
)}
|
||
</div>
|
||
))}
|
||
{Array.from({length: g.homeworldMobility?.advisorSinkMaximum ?? 1}, (_, index) => (
|
||
<div key={index}>{actionButton(`Send ${index + 1} to Polar Sink`, {
|
||
type: 'decision', accept: true, amount: index + 1,
|
||
})}</div>
|
||
))}
|
||
</>
|
||
)}
|
||
{actionButton('Decline shipment', {
|
||
type: 'decision',
|
||
accept: false,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'stoneAllocation' ? (
|
||
g.battle?.stoneAllocation && <StoneBurnerAllocation event={g.decision.event}
|
||
totals={g.battle.stoneAllocation.totals}
|
||
onCommit={undialed => act({type:'decision',event:g.battle!.stoneAllocation!.event,undialed})}
|
||
busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
) : g.decision.kind === 'discoveryEntry' ? (
|
||
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
) : g.decision.kind === 'ecologicalStorm' ? (
|
||
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
) : g.decision.kind === 'discoveryDiscard' ? (
|
||
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? (
|
||
<GreatMakerDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
|
||
) : g.decision.kind === 'nexusFremenProtection' ? (
|
||
<section aria-label="Fremen Nexus worm protection" className="space-y-3">
|
||
<p className="muted">
|
||
Shai-Hulud has appeared in {combatName(g.nexusFremenProtection?.territory ?? '')}.
|
||
Threatened unallied seats with a held Nexus acknowledge in storm order
|
||
before any forces are devoured, regardless of the secret card.
|
||
</p>
|
||
{g.nexusFremenProtection?.blocked ? (
|
||
<p className="notice">{g.nexusFremenProtection.blocked}</p>
|
||
) : <>
|
||
{g.nexusFremenProtection?.canUse && <>
|
||
<p className="notice">Provisional policy: protect only your forces from this worm.
|
||
Spice and other factions remain exposed. This independent protection
|
||
has no additional Karama response and grants no worm ride.</p>
|
||
{actionButton('Spend Nexus · Protect my forces', {
|
||
type: 'nexusFremenProtection', event: g.nexusFremenProtection.event, use: true,
|
||
})}
|
||
</>}
|
||
{g.nexusFremenProtection && actionButton('Continue without worm protection', {
|
||
type: 'nexusFremenProtection', event: g.nexusFremenProtection.event, use: false,
|
||
})}
|
||
</>}
|
||
</section>
|
||
) : g.decision.kind === 'wormProtection' ? (
|
||
<>
|
||
<p className="muted">
|
||
Shai-Hulud has appeared in{' '}
|
||
{combatName(g.decision.territory)}. You may protect your
|
||
ally {g.players.find(player => player.id === (g.decision as {ally:string}).ally)?.name ?? 'at this location'}’s forces from being devoured.
|
||
</p>
|
||
{actionButton('Protect my ally', {
|
||
type: 'decision',
|
||
accept: true,
|
||
...(g.decision.event ? {event:g.decision.event, ally:g.decision.ally} : {}),
|
||
})}
|
||
{actionButton('Do not protect', {
|
||
type: 'decision',
|
||
accept: false,
|
||
...(g.decision.event ? {event:g.decision.event, ally:g.decision.ally} : {}),
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'nexusFremenCunningOffer' ? (
|
||
<>
|
||
<p className="muted">An empty sandworm appearance can trigger Fremen Cunning.
|
||
If you hold your own Fremen Nexus Card, spend it now to arrange a remote
|
||
ride from one occupied desert territory after this Nexus. The worm still resolves normally.</p>
|
||
{g.nexusCards?.card === 'fremen' && actionButton('Spend Fremen Cunning', {
|
||
type: 'decision', event: g.decision.event, accept: true,
|
||
})}
|
||
{actionButton('Continue without Cunning', {
|
||
type: 'decision', event: g.decision.event, accept: false,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'nexusFremenCunningRide' ? (
|
||
<>
|
||
<p className="muted">The Nexus has ended. Choose one occupied desert territory,
|
||
typed forces and a legal destination for the extra ride. The original
|
||
worm appearance has already resolved; this does not consume normal movement.</p>
|
||
<label>Desert source
|
||
<select value={source} onChange={e => {
|
||
setSource(e.target.value);
|
||
setRideForces({});
|
||
setEliteForces({});
|
||
}}>
|
||
<option value="">Choose source</option>
|
||
{[...new Set(Object.entries(me.forces)
|
||
.filter(([key, count]) => count > 0 &&
|
||
splitLocation(key).sector !== g.storm &&
|
||
TERRITORIES.some(t => t.id === splitLocation(key).territory && t.type === 'sand'))
|
||
.map(([key]) => splitLocation(key).territory))].map(id =>
|
||
<option key={id} value={id}>{combatName(id)}</option>)}
|
||
</select>
|
||
</label>
|
||
{Object.entries(me.forces).filter(([key, count]) =>
|
||
count > 0 && splitLocation(key).territory === source &&
|
||
splitLocation(key).sector !== g.storm).map(([key, count]) =>
|
||
<div key={key}>
|
||
<label>Sector {splitLocation(key).sector} · {count} forces
|
||
<Input type="number" min={0} max={count}
|
||
value={rideForces[key] ?? 0}
|
||
onChange={e => setRideForces({
|
||
...rideForces, [key]: Number(e.target.value),
|
||
})} />
|
||
</label>
|
||
{me.elites && (
|
||
<EliteCount label={`Fedaykin from sector ${splitLocation(key).sector}`}
|
||
max={Math.min(rideForces[key] ?? 0, me.elites.forces[key] ?? 0)}
|
||
value={eliteForces[key] ?? 0}
|
||
onChange={n => setEliteForces({ ...eliteForces, [key]: n })} />
|
||
)}
|
||
</div>)}
|
||
{destination}
|
||
{cunningDestinationBlocked && <p className="notice">{cunningDestinationBlocked}</p>}
|
||
<SandmasterWormChoice quote={sandmasterRide} collect={collectSandmasterRide}
|
||
onChange={setCollectSandmasterRide}
|
||
onPileChange={key => setSandmasterRidePile({ target: sandmasterRideTarget, key })} />
|
||
{actionButton('Ride remotely', {
|
||
type: 'decision', event: g.decision.event, accept: true,
|
||
source, territory: selected, sector,
|
||
...(sandmasterRide?.blocked === null
|
||
? { sandmasterCollect: collectSandmasterRide,
|
||
...(collectSandmasterRide ? { sandmasterPile: sandmasterRide.key } : {}) } : {}),
|
||
forces: Object.fromEntries(Object.entries(rideForces)
|
||
.filter(([key, count]) => count > 0 && splitLocation(key).territory === source)
|
||
.map(([key, count]) => [key, {
|
||
normal: count - (eliteForces[key] ?? 0),
|
||
elite: eliteForces[key] ?? 0,
|
||
}])),
|
||
}, !source || !!cunningDestinationBlocked || !Object.entries(rideForces).some(([key, count]) =>
|
||
count > 0 && splitLocation(key).territory === source))}
|
||
{actionButton('Decline remote ride', {
|
||
type: 'decision', event: g.decision.event, accept: false,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'wormRide' ? (
|
||
<>
|
||
<p className="muted">
|
||
The Nexus has concluded. Move some or all of your forces
|
||
from {combatName(g.decision.territory)} to a legal
|
||
territory. This does not use your normal movement.
|
||
</p>
|
||
{Object.entries(me.forces)
|
||
.filter(
|
||
([key, n]) =>
|
||
n > 0 &&
|
||
splitLocation(key).territory ===
|
||
(g.decision as { territory: string }).territory,
|
||
)
|
||
.map(([key, n]) => (
|
||
<label key={key}>
|
||
From sector {splitLocation(key).sector} · {n} forces
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
max={n}
|
||
disabled={splitLocation(key).sector === g.storm}
|
||
value={rideForces[key] ?? 0}
|
||
onChange={(e) =>
|
||
setRideForces({
|
||
...rideForces,
|
||
[key]: Number(e.target.value),
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
))}
|
||
{destination}
|
||
{me.elites &&
|
||
Object.entries(rideForces)
|
||
.filter(
|
||
([key]) =>
|
||
splitLocation(key).territory ===
|
||
(g.decision as { territory: string }).territory,
|
||
)
|
||
.map(([key, n]) => (
|
||
<EliteCount
|
||
key={key}
|
||
label={`Fedaykin from sector ${splitLocation(key).sector}`}
|
||
max={Math.min(n, me.elites!.forces[key] ?? 0)}
|
||
value={eliteForces[key] ?? 0}
|
||
onChange={(n) =>
|
||
setEliteForces({ ...eliteForces, [key]: n })
|
||
}
|
||
/>
|
||
))}
|
||
<SandmasterWormChoice quote={sandmasterRide} collect={collectSandmasterRide}
|
||
onChange={setCollectSandmasterRide}
|
||
onPileChange={key => setSandmasterRidePile({ target: sandmasterRideTarget, key })} />
|
||
{actionButton('Ride Shai-Hulud', {
|
||
type: 'decision',
|
||
...(sandmasterRide?.blocked === null
|
||
? { sandmasterCollect: collectSandmasterRide,
|
||
...(collectSandmasterRide ? { sandmasterPile: sandmasterRide.key } : {}) } : {}),
|
||
eliteForces,
|
||
accept: true,
|
||
territory: selected,
|
||
sector,
|
||
forces: Object.fromEntries(
|
||
Object.entries(rideForces).filter(
|
||
([key]) =>
|
||
splitLocation(key).territory ===
|
||
(g.decision as { territory: string }).territory,
|
||
),
|
||
),
|
||
})}
|
||
{actionButton('Stay in this territory', {
|
||
type: 'decision',
|
||
accept: false,
|
||
})}
|
||
</>
|
||
) : g.decision.kind === 'battleCards' && (
|
||
<>
|
||
<p className="muted">
|
||
You may keep or discard each card played in this battle.
|
||
Select the cards you want to discard.
|
||
</p>
|
||
<div className="visible-component-list">
|
||
{g.decision.cards.map((id) => {
|
||
const card = me.hand?.find((c) => c.id === id);
|
||
return (
|
||
<div key={id}>
|
||
<label className="decision-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={discardCards[id] ?? false}
|
||
onChange={(e) =>
|
||
setDiscardCards({
|
||
...discardCards,
|
||
[id]: e.target.checked,
|
||
})
|
||
}
|
||
/>
|
||
Discard {card?.name ?? id}
|
||
</label>
|
||
{card && <CardInspector card={card} />}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{actionButton('Confirm card choices', {
|
||
type: 'decision',
|
||
discard: g.decision.cards.filter((id) => discardCards[id]),
|
||
})}
|
||
</>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<h2>{PHASES[g.phase]}</h2>
|
||
{g.mentatVictoryPending && (
|
||
<p className="notice">
|
||
Victory will be checked after Mentat actions, the closing
|
||
market and CHOAM’s final Trip to Gamont opportunity.
|
||
</p>
|
||
)}
|
||
<details className="phase-guidance">
|
||
<summary>What happens in this phase?</summary>
|
||
<p>{PHASE_HELP[g.phase]}</p>
|
||
<a
|
||
href={`/rules#${phaseRuleId(g.phase)}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
Open phase guide ↗
|
||
</a>
|
||
</details>
|
||
<ChoamBaliset game={g} act={act} busy={busy} />
|
||
<ChoamGamont game={g} act={act} busy={busy} />
|
||
<ChoamWorthless game={g} act={act} busy={busy} />
|
||
<ChoamBattleFunding game={g} act={act} busy={busy} />
|
||
{g.coalitionFunding && <CoalitionFunding view={g.coalitionFunding} busy={busy} act={act}
|
||
purpose={g.phase === 3 ? 'bid' : 'shipment'}
|
||
{...((shipmentAvailable || g.phase === 3 && g.auction?.active === me.id) ? {onContributionsChange:(shares:CoalitionFundingContribution[]) =>
|
||
setCoalitionShares({key:coalitionFundingKey, shares})} : {})} />}
|
||
{me.ally && [3, 5].includes(g.phase) && (
|
||
<details className="alliance-funding">
|
||
<summary>
|
||
Alliance funding · {g.aid.available} spice available
|
||
</summary>
|
||
<p className="muted">
|
||
Set aside spice for your ally’s purchases this phase.
|
||
Unspent spice returns when the phase ends. Your current
|
||
pledge is {g.aid.pledged}.
|
||
</p>
|
||
<label htmlFor="ally-pledge">
|
||
New unspent pledge
|
||
<Input
|
||
id="ally-pledge"
|
||
type="number"
|
||
min={0}
|
||
max={(me.spice ?? 0) + g.aid.pledged}
|
||
value={aidAmount}
|
||
onChange={(e) => setAidAmount(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
{actionButton('Set ally funding', {
|
||
type: 'pledgeAid',
|
||
amount: aidAmount,
|
||
})}
|
||
<label htmlFor="ally-payment">
|
||
Ally’s share of your next payment
|
||
<Input
|
||
id="ally-payment"
|
||
type="number"
|
||
min={0}
|
||
max={g.aid.available}
|
||
value={allyPayment}
|
||
placeholder="Automatic: cover any shortfall"
|
||
onChange={(e) => setAllyPayment(e.target.value)}
|
||
/>
|
||
</label>
|
||
<p className="fine">
|
||
Leave blank to use your own spice first.
|
||
</p>
|
||
</details>
|
||
)}
|
||
{g.phase === 0 && g.stormPending !== null && (
|
||
<>
|
||
<p className="notice">
|
||
The storm is set to move {g.stormPending} sectors. Play
|
||
Weather Control or Family Atomics before confirming.
|
||
</p>
|
||
{g.stormRevealed && (
|
||
<p className="fine">
|
||
Revealed dials:{' '}
|
||
{Object.values(g.stormRevealed).join(' + ')}
|
||
</p>
|
||
)}
|
||
{g.stormDialers.length === 0 && !g.stormRevealed && (
|
||
<StormCardLogInspector component={g.log.findLast(
|
||
entry => stormCardDistance(entry.component) !== null,
|
||
)?.component} />
|
||
)}
|
||
{actionButton(
|
||
g.ready.includes(me.id)
|
||
? 'Waiting for the table'
|
||
: 'Confirm storm movement',
|
||
{ type: 'ready' },
|
||
g.ready.includes(me.id),
|
||
)}
|
||
</>
|
||
)}
|
||
{g.phase === 0 && g.stormPending === null && g.stormDialers.length > 0 && (
|
||
<>
|
||
<p className="muted">
|
||
The two storm dialers choose secretly. Their sum moves the
|
||
storm.
|
||
</p>
|
||
{g.stormDialers.includes(me.id) &&
|
||
!g.stormSubmitted.includes(me.id) ? (
|
||
<>
|
||
<label htmlFor="storm-dial">
|
||
Storm dial
|
||
<Input
|
||
id="storm-dial"
|
||
type="number"
|
||
min={g.turn === 1 ? 0 : 1}
|
||
max={g.turn === 1 ? 20 : 3}
|
||
value={stormDialValue}
|
||
onChange={(e) => setStormDial(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
{actionButton('Lock storm dial', {
|
||
type: 'stormDial',
|
||
amount: stormDialValue,
|
||
})}
|
||
</>
|
||
) : (
|
||
<p className="fine">Waiting for secret storm dials.</p>
|
||
)}
|
||
</>
|
||
)}
|
||
{g.phase === 1 && g.spiceWindow && (
|
||
<p className="notice">
|
||
{g.spiceWindow.amount} spice revealed in{' '}
|
||
{territory(g.spiceWindow.territory).name}, sector{' '}
|
||
{g.spiceWindow.sector}.
|
||
{g.spiceWindow.sector === g.storm
|
||
? ' The storm destroys this blow.'
|
||
: ''}
|
||
{g.spiceWindow.harvesters
|
||
? ` Harvester has been played ${g.spiceWindow.harvesters} time(s).`
|
||
: ''}
|
||
{harvesterAvailable(g.spiceWindow)
|
||
? ' You may play Harvester before accepting the blow.'
|
||
: ' This blow is closed to Harvester.'}
|
||
</p>
|
||
)}
|
||
{g.phase === 1 &&
|
||
g.players.some((player) => player.faction === 'fremen' && player.fremenNexusWormBlocked) && (
|
||
<p className="notice">
|
||
Fremen Nexus Betrayal was spent before the spice blow.
|
||
Fremen cannot ride worms this turn; ordinary worm effects
|
||
and Nexus negotiations still resolve.
|
||
</p>
|
||
)}
|
||
{g.phase === 1 && g.nexus && !g.spiceWindow && (
|
||
<>
|
||
<p className="muted">
|
||
A sandworm has opened a Nexus. Alliances may change now.
|
||
</p>
|
||
{groupNexus && <CoalitionNexus view={groupNexus} disabled={busy} onIntent={(intent) => {
|
||
if (intent.kind === 'propose') act({type: 'coalitionPropose', groupId: intent.groupId,
|
||
entrant: intent.entrant, expectedRevision: intent.expectedRevision});
|
||
else if (intent.kind === 'answer') act({type: 'coalitionAnswer', event: intent.event,
|
||
nexus: intent.nexus, turn: intent.turn, accept: intent.accept, expectedRevision: intent.expectedRevision});
|
||
else act({type: 'coalitionLeave', expectedRevision: intent.expectedRevision});
|
||
}} />}
|
||
{!groupNexus?.proposal && !inGroup && <>
|
||
{outgoingAlliance && !me.ally && (
|
||
<div className="nexus-offer">
|
||
<p className="notice">
|
||
Your alliance offer is to {outgoingAllianceName ?? 'a player no longer seated'}.
|
||
You remain unallied until both players agree.
|
||
</p>
|
||
{actionButton('Withdraw your alliance offer', { type: 'alliance' })}
|
||
</div>
|
||
)}
|
||
<label>
|
||
Alliance partner
|
||
<select
|
||
value={target}
|
||
onChange={(e) => setTarget(e.target.value)}
|
||
>
|
||
<option value="">Choose a player</option>
|
||
{g.players
|
||
.filter((p) => p.id !== me.id && !groupNexus?.groups.some(group =>
|
||
group.members.length === 3 && group.members.includes(p.id)))
|
||
.map((p) => (
|
||
<option value={p.id} key={p.id}
|
||
disabled={!!g.homeworldAllianceBlocks?.[p.id]}>
|
||
{p.name}
|
||
{g.homeworldAllianceBlocks?.[p.id] ? ` · ${g.homeworldAllianceBlocks[p.id]}` : ''}
|
||
{g.allianceOffers[p.id] === me.id ? ' · Invited you' : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{g.homeworldAllianceBlocks?.[target] && <p className="notice">{g.homeworldAllianceBlocks[target]}</p>}
|
||
{actionButton('Propose / accept alliance', {
|
||
type: 'alliance',
|
||
target,
|
||
}, !target || !!g.homeworldAllianceBlocks?.[target])}
|
||
{me.ally &&
|
||
actionButton('Break alliance', { type: 'alliance' })}
|
||
</>}
|
||
</>
|
||
)}
|
||
{g.phase === 1 && !g.nexus && !g.spiceWindow && (
|
||
<p className="muted">
|
||
Ready the table to reveal the next spice blow.
|
||
</p>
|
||
)}
|
||
{g.phase === 2 && <CharityClaim game={g} act={act} busy={busy} />}
|
||
{g.phase === 3 && g.richeseAuction && (
|
||
<>
|
||
<RicheseBidPromisePanel promises={g.richeseBidPromises} guide={g.richeseBidPromiseGuide} game={g} act={act} busy={busy} />
|
||
<RicheseAuctionLot
|
||
key={`${g.richeseAuction.event}-${g.me}`}
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
/>
|
||
</>
|
||
)}
|
||
{g.phase === 3 && !g.richeseAuction && g.auction && (
|
||
<>
|
||
<div className="auction-card">
|
||
<span className="eyebrow">
|
||
{g.auction.remaining} CARDS REMAIN
|
||
</span>
|
||
<h3>{g.auction.card?.name ?? 'Unknown treachery'}</h3>
|
||
{g.auction.card && <CardInspector card={g.auction.card} />}
|
||
{g.auction.inspectionBlocked && (
|
||
<output className="notice block">{g.auction.inspectionBlocked}</output>
|
||
)}
|
||
{!!g.ixTechnology?.known.length && (
|
||
<details>
|
||
<summary>Your inspected pool · order unknown</summary>
|
||
<ul className="visible-component-list">
|
||
{g.ixTechnology.known.map((c) => (
|
||
<li key={c.id}>
|
||
<span>{c.name}</span>
|
||
<CardInspector card={c} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<p className="fine">
|
||
This records the pool you inspected, including cards
|
||
already auctioned; it does not reveal the next card or
|
||
other hands.
|
||
</p>
|
||
</details>
|
||
)}
|
||
<strong>
|
||
{g.auction.bid} <small>spice</small>
|
||
</strong>
|
||
</div>
|
||
{g.auction.active === me.id ? (
|
||
<>
|
||
<label htmlFor="your-bid">
|
||
Your bid
|
||
<Input
|
||
id="your-bid"
|
||
type="number"
|
||
min={minimumBid}
|
||
max={maximumBid}
|
||
value={bidText}
|
||
onChange={(e) =>
|
||
setBidDraft({
|
||
auction: auctionKey,
|
||
value: e.target.value,
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
<p className="fine">
|
||
Minimum bid: {minimumBid} spice.
|
||
{normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit) &&
|
||
' Your Treachery hand is full; pass this bid.'}
|
||
{maximumBid < minimumBid &&
|
||
' You cannot afford a higher bid; pass this card.'}
|
||
</p>
|
||
{actionButton(
|
||
'Raise bid',
|
||
{
|
||
type: 'bid',
|
||
amount: bidAmount,
|
||
...payment,
|
||
...(g.coalitionFunding && currentCoalitionShares.length
|
||
? {allyPayment:0, coalitionContributions:currentCoalitionShares} : {}),
|
||
},
|
||
!!normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit) ||
|
||
!Number.isSafeInteger(bidAmount) ||
|
||
bidAmount < minimumBid ||
|
||
bidAmount > maximumBid ||
|
||
!!(g.coalitionFunding && currentCoalitionShares.length &&
|
||
(groupShipmentShare > bidAmount || bidAmount - groupShipmentShare > (me.spice ?? 0))),
|
||
)}
|
||
{actionButton('Pass this bid', { type: 'passBid' })}
|
||
</>
|
||
) : (
|
||
<p className="muted">
|
||
Waiting for{' '}
|
||
{g.players.find((p) => p.id === g.auction!.active)?.name}.
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
{me.faction === 'emperor' && me.ally && (
|
||
<details className="phase-guide">
|
||
<summary>Share wealth with your ally</summary>
|
||
<p className="fine">
|
||
Spice becomes available immediately after the Karama
|
||
response.
|
||
</p>
|
||
<label htmlFor="emperor-gift">
|
||
Spice
|
||
<Input
|
||
id="emperor-gift"
|
||
type="number"
|
||
min={1}
|
||
max={me.spice ?? 0}
|
||
value={aidAmount}
|
||
onChange={(e) => setAidAmount(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
{actionButton(
|
||
'Share spice',
|
||
{ type: 'emperorGift', amount: aidAmount },
|
||
aidAmount < 1,
|
||
)}
|
||
</details>
|
||
)}
|
||
{g.coalitionEmperor && <CoalitionEmperorAid
|
||
allies={g.players.filter(player => g.coalitionEmperor!.allies.includes(player.id))}
|
||
phase={g.phase} advanced={g.advanced} availableSpice={g.coalitionEmperor.availableSpice}
|
||
emperorExtra={g.emperorExtra} revivalOrderHead={g.revivalOrder?.remaining[0]}
|
||
busy={busy} act={act} />}
|
||
{g.phase === 4 && (
|
||
<>
|
||
<RevivalTurn game={g} act={act} busy={busy} />
|
||
{g.revival.prevented && (
|
||
<p className="notice">
|
||
Tleilaxu prevented your force and leader revivals for
|
||
the rest of this Revival, including Ghola Treachery.
|
||
The Emperor’s native special Karama remains immune;
|
||
this prevention does not stop Ghola outside Revival.
|
||
</p>
|
||
)}
|
||
<RevivalCommerce game={g} act={act} busy={busy} />
|
||
{g.revival.homeworldBonus > 0 && (
|
||
<p className="notice">
|
||
Low Homeworld population adds one to your free revival
|
||
rate. The next request uses your population after this
|
||
group returns.
|
||
{g.revival.freeBlocked
|
||
? ' La La La currently prevents taking any free revivals.'
|
||
: ''}
|
||
</p>
|
||
)}
|
||
{g.revival.tleilaxuHomeworldIncomeBlocked && (
|
||
<p className="notice">
|
||
Tleilax started Revival at low population. Tleilaxu
|
||
receives no bank reward for other factions’ free revivals
|
||
this phase; paid revival and Ghola income remain separate.
|
||
</p>
|
||
)}
|
||
{g.revival.freeBlocked && (
|
||
<p className="notice">
|
||
La La La prevents your free force revivals for this phase.
|
||
{me.faction === 'fremen' &&
|
||
!g.players.some((p) => p.faction === 'tleilaxu')
|
||
? ' Fremen cannot purchase normal force revivals without Tleilaxu in the game.'
|
||
: ' Normal paid revivals remain available.'}
|
||
</p>
|
||
)}
|
||
<p className="fine">
|
||
{g.revival.limit === 20
|
||
? 'No normal force revival limit'
|
||
: `Up to ${g.revival.limit} force revivals`}{' '}
|
||
· {g.revival.freeRemaining} free remaining ·{' '}
|
||
{me.faction === 'fremen' &&
|
||
!g.players.some((p) => p.faction === 'tleilaxu')
|
||
? 'Fremen force revival is free only without Tleilaxu'
|
||
: me.faction === 'choam' && !g.revival.choamBlocked
|
||
? 'One spice per paid force'
|
||
: 'Standard force prices'}
|
||
{g.revival.discount
|
||
? ' · Tleilaxu half-price discount'
|
||
: ''}
|
||
</p>
|
||
<FremenRevivalGrant game={g} act={act} busy={busy} />
|
||
{g.freeRevival.includes(me.id) && !g.revival.freeBlocked && (
|
||
<p className="muted">
|
||
Fremen’s grant is active. Your current free allowance above includes Recruits and any applicable bonuses.
|
||
</p>
|
||
)}
|
||
{amountInput}
|
||
{me.faction === 'ixians' && (
|
||
<label>
|
||
Use free revival for
|
||
<select
|
||
value={freeCyborgFirst ? 'cyborg' : 'suboid'}
|
||
onChange={(e) =>
|
||
setFreeCyborgFirst(e.target.value === 'cyborg')
|
||
}
|
||
>
|
||
<option value="cyborg">Cyborgs first</option>
|
||
<option value="suboid">Suboids first</option>
|
||
</select>
|
||
<span className="fine">
|
||
Paid cyborgs cost three spice; suboids cost two. Cyborgs
|
||
do not share the one-per-turn Sardaukar/Fedaykin limit.
|
||
</span>
|
||
</label>
|
||
)}
|
||
{me.faction === 'ixians' && (
|
||
<IxHomeworldRevivalBonus
|
||
enabled={g.revival.ixSuboidBonusEnabled}
|
||
blocked={g.revival.freeBlocked}
|
||
amount={amount}
|
||
elite={eliteAmount}
|
||
free={Math.min(amount, g.revival.freeRemaining)}
|
||
freeElite={freeCyborgFirst
|
||
? Math.min(amount, g.revival.freeRemaining, eliteAmount)
|
||
: Math.max(0, Math.min(amount, g.revival.freeRemaining) - (amount - eliteAmount))}
|
||
normalTanks={me.tanks - (me.elites?.tanks ?? 0)}
|
||
/>
|
||
)}
|
||
{me.faction === 'emperor' &&
|
||
me.ally &&
|
||
(() => {
|
||
const ally = g.players.find((p) => p.id === me.ally)!;
|
||
const cost = paidForceRevivalCost(
|
||
ally,
|
||
amount,
|
||
ally.elites ? allyEliteAmount : 0,
|
||
);
|
||
return (
|
||
<div className="notice">
|
||
<h3>Extra revivals for {ally.name}</h3>
|
||
{ally.elites && (
|
||
<EliteCount
|
||
label={
|
||
ally.faction === 'ixians'
|
||
? 'Ally cyborgs'
|
||
: 'Ally elite forces'
|
||
}
|
||
value={allyEliteAmount}
|
||
onChange={setAllyEliteAmount}
|
||
max={Math.min(
|
||
amount,
|
||
eliteRevivalRemaining(ally, g.advanced),
|
||
)}
|
||
/>
|
||
)}
|
||
<p className="fine">
|
||
You pay {cost} spice. These forces use your ally’s
|
||
separate extra-revival allowance.
|
||
</p>
|
||
{actionButton(
|
||
'Pay for extra ally revivals',
|
||
{
|
||
type: 'emperorRevival',
|
||
amount,
|
||
elite: ally.elites ? allyEliteAmount : 0,
|
||
},
|
||
cost > (me.spice ?? 0) ||
|
||
(!!g.revivalOrder && g.revivalOrder.remaining[0] !== ally.id) ||
|
||
amount > 3 - (g.emperorExtra[ally.id] ?? 0),
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
{g.revival.kwisatz &&
|
||
actionButton(
|
||
`Revive Kwisatz Haderach · ${g.revival.kwisatz.cost} spice`,
|
||
{
|
||
type: 'reviveKwisatz',
|
||
},
|
||
!!g.revival.orderBlocked || !g.revival.kwisatz.affordable,
|
||
)}
|
||
{(g.emperorExtra[me.id] ?? 0) > 0 && (
|
||
<p className="fine">
|
||
The Emperor revived {g.emperorExtra[me.id]} extra forces
|
||
for you. Your normal revival allowance is separate.
|
||
</p>
|
||
)}
|
||
{actionButton(
|
||
'Revive forces',
|
||
{
|
||
type: 'revive',
|
||
amount,
|
||
elite: eliteAmount,
|
||
freeElite:
|
||
me.faction === 'ixians' && !freeCyborgFirst
|
||
? Math.max(
|
||
0,
|
||
Math.min(amount, g.revival.freeRemaining) -
|
||
(amount - eliteAmount),
|
||
)
|
||
: undefined,
|
||
},
|
||
!!g.revival.orderBlocked || g.revival.prevented ||
|
||
!me.tanks ||
|
||
eliteAmount > g.revival.eliteRemaining ||
|
||
amount > g.revival.forcesRemaining ||
|
||
!!homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount }),
|
||
)}
|
||
{eliteAmount > g.revival.eliteRemaining && (
|
||
<p className="fine" aria-live="polite">
|
||
{g.revival.eliteRemaining} special-force revivals remain this turn.
|
||
Reduce the selected elite count.
|
||
</p>
|
||
)}
|
||
{homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount }) && (
|
||
<p className="fine" aria-live="polite">
|
||
{homeworldRevivalActionBlock(g, { type: 'revive', amount, elite: eliteAmount })}
|
||
</p>
|
||
)}
|
||
{me.faction === 'fremen' &&
|
||
amount > g.revival.forcesRemaining ? (
|
||
<p className="fine">
|
||
Choose no more than {g.revival.forcesRemaining} available
|
||
free force revivals. Emperor-funded extras and card
|
||
effects are separate.
|
||
</p>
|
||
) : (
|
||
<p className="fine">
|
||
Selected force revival costs{' '}
|
||
{
|
||
forceRevivalPrice(
|
||
me.faction,
|
||
amount,
|
||
eliteAmount,
|
||
Math.min(amount, g.revival.freeRemaining),
|
||
g.revival.discount,
|
||
me.faction === 'ixians' && !freeCyborgFirst
|
||
? Math.max(
|
||
0,
|
||
Math.min(amount, g.revival.freeRemaining) -
|
||
(amount - eliteAmount),
|
||
)
|
||
: undefined,
|
||
g.revival.choamBlocked,
|
||
!!g.revival.sardaukarFreeBlock,
|
||
).cost
|
||
}{' '}
|
||
spice.
|
||
</p>
|
||
)}
|
||
{g.revival.sardaukarFreeBlock && (
|
||
<p className="fine">{g.revival.sardaukarFreeBlock} Ordinary Sardaukar revival remains available at the displayed paid price.</p>
|
||
)}
|
||
{g.revival.auditorCyclePolicy && (
|
||
<p className="notice">{g.revival.auditorCyclePolicy}</p>
|
||
)}
|
||
{g.revival.leaders.length > 0
|
||
? (() => {
|
||
const selectedRevival = g.revival.leaders.find(
|
||
(choice) => choice.id === leader,
|
||
);
|
||
return (
|
||
<>
|
||
<label htmlFor="normal-revival-leader">
|
||
Leader
|
||
<select
|
||
id="normal-revival-leader"
|
||
value={selectedRevival?.id ?? ''}
|
||
onChange={(e) => setLeader(e.target.value)}
|
||
>
|
||
<option value="">
|
||
Choose an eligible leader
|
||
</option>
|
||
{g.revival.leaders.map((choice) => (
|
||
<option value={choice.id} key={choice.id}>
|
||
{choice.name} · {choice.cost} spice
|
||
{choice.id === 'duke-vidal' ? ' · strength 6, set aside after revival' : ''}
|
||
{choice.early ? ' · early revival' : ''}
|
||
{!choice.affordable
|
||
? ' · insufficient spice'
|
||
: ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{actionButton(
|
||
'Revive leader',
|
||
{
|
||
type: 'reviveLeader',
|
||
leader: selectedRevival?.id ?? '',
|
||
},
|
||
!!g.revival.orderBlocked || !selectedRevival?.affordable,
|
||
)}
|
||
</>
|
||
);
|
||
})()
|
||
: (me.leaders.some((l) => l.dead && l.faction === me.faction) ||
|
||
(me.faction === 'ecaz' && g.dukeVidal?.leader.dead)) && (
|
||
<p className="fine">
|
||
{me.leaderRevived && me.faction !== 'tleilaxu'
|
||
? 'You have used your one leader revival for this phase.'
|
||
: g.revival.prevented
|
||
? 'Tleilaxu prevented your normal revivals this turn.'
|
||
: g.revival.dukeBlocked && me.faction === 'ecaz' && g.dukeVidal?.leader.dead
|
||
? g.revival.dukeBlocked
|
||
: 'No leader is currently eligible for normal revival.'}
|
||
</p>
|
||
)}
|
||
{g.revival.ecazCyclePolicy && <p className="fine">{g.revival.ecazCyclePolicy}</p>}
|
||
</>
|
||
)}
|
||
{g.phase === 5 && (
|
||
<ShipmentPromises game={g} act={act} busy={busy} />
|
||
)}
|
||
{g.phase === 5 && g.active === me.id && g.homeworldShipment && shipmentAvailable && (
|
||
<HomeworldShipment key={g.homeworldShipment.event} game={g} act={act} busy={busy} />
|
||
)}
|
||
{g.phase === 5 && g.active === me.id && g.guildHomeworldShipment && shipmentAvailable && (
|
||
<GuildHomeworldShipment key={g.guildHomeworldShipment.event} game={g} act={act} busy={busy} />
|
||
)}
|
||
{g.phase === 5 && g.homeworldMove && (
|
||
<EmperorHomeworldMovement
|
||
key={g.homeworldMove.event}
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
/>
|
||
)}
|
||
{g.phase === 5 &&
|
||
me.faction === 'choam' &&
|
||
g.choamMovementBonus > 0 && (
|
||
<p className="notice">
|
||
Kulon: +{g.choamMovementBonus} territory of movement range
|
||
this turn. Storm and entry rules still apply.
|
||
</p>
|
||
)}
|
||
{g.phase === 5 &&
|
||
g.balisetRestrictions
|
||
.filter((b) => b.player === me.id)
|
||
.map((b) => (
|
||
<p className="notice" key={b.territory}>
|
||
Baliset: movement into{' '}
|
||
{
|
||
gameTerritories(g).find((t) => t.id === b.territory)
|
||
?.name
|
||
}{' '}
|
||
is blocked while CHOAM occupies it. Shipment remains
|
||
possible.
|
||
</p>
|
||
))}
|
||
{g.phase === 5 &&
|
||
me.faction === 'fremen' &&
|
||
me.fremenMovementBlocked && (
|
||
<p className="notice">
|
||
{me.fremenNexusMovementBlocked
|
||
? 'Fremen Nexus Betrayal suppresses your two-territory movement advantage for this turn. Available ornithopters still use their normal range.'
|
||
: 'Karama limited this move to one territory. Your forces have not moved yet. Available ornithopters still allow their normal range; a later extra move can use your two-territory advantage again.'}
|
||
</p>
|
||
)}
|
||
{g.phase === 5 && me.faction === 'ixians' && (
|
||
<p className="fine">
|
||
Select a cyborg to take accompanying suboids two territories.{' '}
|
||
<HelpTip topic="ixTransport" />
|
||
{me.ixMovementBlocked &&
|
||
' Cyborg speed was canceled for this move; use one territory or available ornithopters.'}
|
||
</p>
|
||
)}
|
||
{g.phase === 5 &&
|
||
(g.active === me.id ? (
|
||
<>
|
||
{g.advanced &&
|
||
me.faction === 'ixians' &&
|
||
!me.specialKaramaUsed &&
|
||
g.mobileStronghold?.location &&
|
||
(me.forces['hidden_mobile_stronghold:0'] ?? 0) > 0 &&
|
||
me.hand
|
||
?.filter(canActivateKarama)
|
||
.slice(0, 1)
|
||
.map((c) => (
|
||
<details key={c.id}>
|
||
<summary>
|
||
Special Karama: relocate stronghold
|
||
</summary>
|
||
<MobileStronghold
|
||
key={`${g.turn}-${g.mobileStronghold?.location}-${c.id}`}
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
card={c.id}
|
||
/>
|
||
</details>
|
||
))}
|
||
{!me.shipped && g.nexusGuildSecretAlly && (
|
||
<div className="notice">
|
||
<label className="decision-checkbox min-h-11">
|
||
<input type="checkbox" checked={useGuildSecret} disabled={busy || !!g.nexusGuildSecretAlly.blocked}
|
||
onChange={event => setGuildSecretEvent(event.target.checked ? g.nexusGuildSecretAlly!.event : '')} />
|
||
Use Guild Nexus Secret Ally
|
||
</label>
|
||
<p>Spend the Nexus for this shipment at Guild prices: one spice per two forces to a stronghold or reserves, rounded up, or one per force elsewhere. Cross-shipment and return use the same shipment opportunity; normal movement remains available.</p>
|
||
{me.faction === 'fremen' && <p>Leave this unchecked for free Fremen reinforcement within its normal range. Selecting the Nexus pays Guild prices and permits destinations beyond that range, outside the storm.</p>}
|
||
{g.homeworlds?.worlds?.length && <p>Returning Arrakis forces to native Homeworld reserves with this card awaits a ruling.</p>}
|
||
{g.nexusGuildSecretAlly.blocked && <p>{g.nexusGuildSecretAlly.blocked}</p>}
|
||
</div>
|
||
)}
|
||
{!me.shipped && g.nexusRichese && (
|
||
<div className="notice">
|
||
<label className="decision-checkbox min-h-11">
|
||
<input type="checkbox" checked={useRicheseShipment}
|
||
disabled={busy || !!g.nexusRichese.blocked}
|
||
onChange={(event) => setRicheseShipmentEvent(event.target.checked ? g.nexusRichese!.event : '')} />
|
||
Use Richese Nexus Secret Ally
|
||
</label>
|
||
<p>Spend the Nexus to ship up to five physical forces from reserves at the price of one. This uses your ordinary shipment; movement remains available.</p>
|
||
{g.nexusRichese.blocked && <p>{g.nexusRichese.blocked}</p>}
|
||
{useRicheseShipment && <p>Physical forces selected: {amount}. Forces charged: 1. Ordinary destination and faction restrictions still apply.</p>}
|
||
</div>
|
||
)}
|
||
{destination}
|
||
{isAlliedShipmentVisit(g, me, selected) && (
|
||
<p className="notice">
|
||
{g.advanced
|
||
? 'Advanced allied transit: ship ordinary forces here, then depart before finishing. Supplied p15 sends all your forces left sharing with an ally to Tanks, including old forces; advisor, Polar Sink and native Ecaz coexistence exemptions remain.'
|
||
: 'Basic allied transit: ship forces or your own concealed No-Field here, then move the visiting group out before finishing your turn. Provisional fallback: new visitors left behind go to Tanks, even if departure is prevented. This does not waive the older allied co-occupation deadline.'}
|
||
{' '}Storm and stronghold capacity still apply.
|
||
</p>
|
||
)}
|
||
{amountInput}
|
||
{g.karamaShipping?.player === me.id && (
|
||
<p className="fine">
|
||
Karama shipment: pay Guild rates to the bank. Choose
|
||
your shipment below.
|
||
</p>
|
||
)}
|
||
{shipmentAvailable && (
|
||
<>
|
||
<NativeShipmentChoice
|
||
game={g}
|
||
amount={amount}
|
||
elite={eliteAmount}
|
||
sources={homeworldSources}
|
||
busy={busy}
|
||
onChange={(sources) =>
|
||
setHomeworldDraft({
|
||
key: homeworldSourceKey,
|
||
sources,
|
||
})
|
||
}
|
||
/>
|
||
<ShipmentQuote
|
||
id="reserve-shipment-quote"
|
||
physicalForces={useRicheseShipment || smugglerShipment ? amount : undefined}
|
||
quote={shipmentQuote}
|
||
funding={{
|
||
ownSpice: me.spice ?? 0,
|
||
pledgedSpice: availableShipmentAid,
|
||
}}
|
||
unavailableReasons={shipmentProblems}
|
||
/>
|
||
<SmugglerShipmentChoice quote={smugglerOffer} use={useSmuggler} busy={busy || !!g.truthtrance} onChange={setUseSmuggler} />
|
||
<Button
|
||
className="game-action"
|
||
aria-describedby="reserve-shipment-quote"
|
||
disabled={
|
||
busy ||
|
||
!!g.truthtrance ||
|
||
shipmentProblems.length > 0
|
||
}
|
||
onClick={() => {
|
||
const shipment: Action = {
|
||
type: 'ship', elite: eliteAmount, ...payment,
|
||
territory: selected, sector, amount,
|
||
...(g.coalitionFunding && currentCoalitionShares.length
|
||
? {allyPayment: 0, coalitionContributions: currentCoalitionShares} : {}),
|
||
...(smugglerOffer ? { smuggler: useSmuggler } : {}),
|
||
...(homeworldSources ? { homeworldSources } : {}),
|
||
};
|
||
const action = useGuildSecret ? nexusGuildSecretAllyAction(g, guildSecretEvent, shipment) : useRicheseShipment
|
||
? nexusRicheseAction(g, richeseShipmentEvent, shipment) : shipment;
|
||
if (action) act(action);
|
||
}}
|
||
>
|
||
Ship from reserves <ArrowRight size={15} />
|
||
</Button>
|
||
</>
|
||
)}
|
||
{sourceInput}
|
||
{markerAtSource && ownNoField && (
|
||
<label className="decision-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={includesNoField}
|
||
disabled={busy || !!g.homeworldMobility?.noFieldMovementBlocked}
|
||
onChange={(event) =>
|
||
setMoveNoField(event.target.checked)
|
||
}
|
||
/>
|
||
Include concealed No-Field from sector{' '}
|
||
{ownNoField.location.sector}
|
||
</label>
|
||
)}
|
||
{includesNoField && (
|
||
<p className="fine">
|
||
Moving {markerPhysicalTotal} physical forces and one
|
||
concealed No-Field. Choose zero physical forces to move
|
||
only the marker. Its hidden value stays unchanged.
|
||
</p>
|
||
)}
|
||
{markerAtSource && g.homeworldMobility?.noFieldMovementBlocked && (
|
||
<p className="fine">{g.homeworldMobility.noFieldMovementBlocked} Physical forces may still move without the token.</p>
|
||
)}
|
||
<label className="decision-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={combineSectors}
|
||
onChange={(e) => setCombineSectors(e.target.checked)}
|
||
/>
|
||
Combine forces from this territory’s sectors
|
||
</label>
|
||
{combineSectors &&
|
||
source &&
|
||
Object.entries(me.forces)
|
||
.filter(
|
||
([key, n]) =>
|
||
n > 0 &&
|
||
splitLocation(key).territory ===
|
||
splitLocation(source).territory,
|
||
)
|
||
.map(([key, n]) => (
|
||
<label key={key}>
|
||
Sector {splitLocation(key).sector} · {n} forces
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
max={n}
|
||
value={moveForces[key] ?? 0}
|
||
disabled={splitLocation(key).sector === g.storm}
|
||
onChange={(e) =>
|
||
setMoveForces({
|
||
...moveForces,
|
||
[key]: Number(e.target.value),
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
))}
|
||
{combineSectors &&
|
||
me.elites &&
|
||
Object.entries(me.forces)
|
||
.filter(
|
||
([key]) =>
|
||
splitLocation(key).territory ===
|
||
splitLocation(source).territory,
|
||
)
|
||
.map(([key]) => (
|
||
<EliteCount
|
||
key={key}
|
||
label={`Elite from sector ${splitLocation(key).sector}`}
|
||
max={Math.min(
|
||
moveForces[key] ?? 0,
|
||
me.elites!.forces[key] ?? 0,
|
||
)}
|
||
value={eliteForces[key] ?? 0}
|
||
onChange={(n) =>
|
||
setEliteForces({ ...eliteForces, [key]: n })
|
||
}
|
||
/>
|
||
))}
|
||
{g.advanced &&
|
||
isAdvisor(me, splitLocation(source).territory) && (
|
||
<label className="checkbox-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={moveAsFighters}
|
||
onChange={(e) =>
|
||
setMoveAsFighters(e.target.checked)
|
||
}
|
||
/>
|
||
Request a flip to fighters after entering an occupied
|
||
territory
|
||
</label>
|
||
)}
|
||
{advisorFlipRouteBlocked && (
|
||
<p className="notice" id="movement-advisor-flip-unavailable">
|
||
Advisor flipping was canceled for this movement. Turn off
|
||
the fighter request or choose a group that is already fighting.
|
||
</p>
|
||
)}
|
||
{!g.ornithopter?.active && (
|
||
<>
|
||
{actionButton(
|
||
includesNoField
|
||
? 'Move selected forces and No-Field'
|
||
: 'Move forces',
|
||
ordinaryMovementAction,
|
||
!source ||
|
||
canceledFremenRouteBlocked ||
|
||
ordinaryMovementRouteBlocked ||
|
||
advisorFlipRouteBlocked ||
|
||
selectedDestinationInStorm ||
|
||
(me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
|
||
(includesNoField
|
||
? !validMarkerGroup
|
||
: (me.forces[source] ?? 0) === 0),
|
||
advisorFlipRouteBlocked
|
||
? 'movement-advisor-flip-unavailable'
|
||
: selectedDestinationInStorm
|
||
? 'movement-storm-unavailable'
|
||
: ordinaryMovementRouteBlocked
|
||
? 'movement-route-unavailable'
|
||
: canceledFremenRouteBlocked
|
||
? 'movement-range-unavailable'
|
||
: undefined,
|
||
)}
|
||
{canceledFremenRouteBlocked &&
|
||
!selectedDestinationInStorm && (
|
||
<p
|
||
id="movement-range-unavailable"
|
||
className="fine"
|
||
role="status"
|
||
>
|
||
This route is blocked or beyond your available
|
||
range. Choose a reachable destination within one
|
||
territory, or use available ornithopters.
|
||
</p>
|
||
)}
|
||
{ordinaryMovementRouteBlocked && !selectedDestinationInStorm && (
|
||
<p id="movement-route-unavailable" className="fine" role="status">
|
||
Movement unavailable: this force group has no unblocked
|
||
ground route within its ordinary range. Choose a reachable
|
||
source or destination outside the storm and blocked strongholds.
|
||
</p>
|
||
)}
|
||
{selectedDestinationInStorm && (
|
||
<p
|
||
id="movement-storm-unavailable"
|
||
className="fine"
|
||
role="status"
|
||
>
|
||
Movement unavailable: sector {sector} of{' '}
|
||
{territory(selected).name} is in the storm. Choose a
|
||
different sector or territory.
|
||
</p>
|
||
)}
|
||
<SandmasterMovement
|
||
game={g}
|
||
move={source ? ordinaryMovementAction : null}
|
||
act={act}
|
||
busy={busy}
|
||
unavailableReason={
|
||
!movementAvailable && !g.ornithopter?.active
|
||
? 'No ordinary movement is currently available.'
|
||
: !g.ornithopter?.active && (me.moved ?? 0) >= (me.movesAllowed ?? 1)
|
||
? 'No ordinary movement remains this turn.'
|
||
: selectedDestinationInStorm
|
||
? 'Choose a destination outside the storm.'
|
||
: liveShipmentPromises(
|
||
g.shipmentPromises,
|
||
me.id,
|
||
g.turn,
|
||
).some(promise => matchesShipment(promise,null) !== promise.answer)
|
||
? 'Complete the promised shipment before moving.'
|
||
: null
|
||
}
|
||
/>
|
||
</>
|
||
)}
|
||
<OrnithopterMovement
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
move={
|
||
!source ||
|
||
!movementAvailable ||
|
||
advisorFlipRouteBlocked ||
|
||
(me.moved ?? 0) >= (me.movesAllowed ?? 1) ||
|
||
(includesNoField
|
||
? !validMarkerGroup
|
||
: (me.forces[source] ?? 0) === 0)
|
||
? null
|
||
: {
|
||
type: 'move',
|
||
fighters:
|
||
moveAsFighters &&
|
||
isAdvisor(me, splitLocation(source).territory),
|
||
...movementGroup,
|
||
territory: selected,
|
||
sector,
|
||
...(includesNoField ? {} : { amount }),
|
||
}
|
||
}
|
||
/>
|
||
<DiscoveryOrnithopterMovement
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
move={
|
||
!source || !movementAvailable || advisorFlipRouteBlocked ||
|
||
(includesNoField ? !validMarkerGroup : (me.forces[source] ?? 0) === 0)
|
||
? null
|
||
: {
|
||
type: 'move',
|
||
fighters: moveAsFighters && isAdvisor(me, splitLocation(source).territory),
|
||
...movementGroup,
|
||
territory: selected,
|
||
sector,
|
||
...(includesNoField ? {} : { amount }),
|
||
}
|
||
}
|
||
/>
|
||
<PlanetologistMovement
|
||
game={g}
|
||
act={act}
|
||
busy={busy}
|
||
destination={selected}
|
||
sector={sector}
|
||
unavailableReason={
|
||
!movementAvailable && !g.ornithopter?.active
|
||
? 'No ordinary movement is currently available.'
|
||
: !g.ornithopter?.active && (me.moved ?? 0) >= (me.movesAllowed ?? 1)
|
||
? 'No ordinary movement remains this turn.'
|
||
: selectedDestinationInStorm
|
||
? 'Choose a destination outside the storm.'
|
||
: liveShipmentPromises(
|
||
g.shipmentPromises,
|
||
me.id,
|
||
g.turn,
|
||
).some(promise => matchesShipment(promise,null) !== promise.answer)
|
||
? 'Complete the promised shipment before moving.'
|
||
: null
|
||
}
|
||
/>
|
||
<p className="fine">
|
||
{guildCunning?.stage === 'secondShipment'
|
||
? 'Choose or decline the second shipment before any Hajr movement.'
|
||
: guildCunning?.stage === 'extraMove'
|
||
? `${guildCunning.movesLeft} movement remaining from unused Hajr or the active card. No ordinary movement is granted.`
|
||
: (me.moved ?? 0) < (me.movesAllowed ?? 1)
|
||
? `${(me.movesAllowed ?? 1) - (me.moved ?? 0)} movement remaining.`
|
||
: 'Movement complete. Finish your turn when your shipment is also settled.'}
|
||
</p>
|
||
{g.ornithopter && <p className="fine">Provisional card composition: Ornithopter replaces one unused movement. Its two groups must be different; Hajr adds one separate normal move and may move those forces again.</p>}
|
||
{g.advanced && me.hand?.some(card => card.effect === 'hajr') && <p className="fine">Play Hajr immediately after a completed Force Movement and its required arrival decisions, before your next voluntary action.</p>}
|
||
{!!me.hajrPlays && <p className="fine">{me.hajrPlays} Hajr play{me.hajrPlays === 1 ? '' : 's'} this turn. Each legal play grants one additional movement. A recovered Hajr must return to your hand; Advanced requires a fresh post-move opportunity.</p>}
|
||
{transportPreview && (
|
||
<>
|
||
{includesNoField && <p className="fine">
|
||
Provisional No-Field transport: cross-shipment keeps your marker concealed and prices it as one force.
|
||
A permitted Secret Ally return sends it back face down without placing forces or clearing the last-token restriction.
|
||
Your ordinary movement remains available.
|
||
</p>}
|
||
<ShipmentQuote
|
||
id="guild-transport-quote"
|
||
label={`Guild transport to ${territory(selected).name}`}
|
||
{...transportPreview}
|
||
/>
|
||
{actionButton(
|
||
'Guild transport',
|
||
transportAction,
|
||
transportPreview.unavailableReasons.length > 0,
|
||
'guild-transport-quote',
|
||
)}
|
||
{southernTransportPreview && (
|
||
<>
|
||
<ShipmentQuote
|
||
id="southern-transport-quote"
|
||
label={`Southern reserves to ${territory(selected).name}`}
|
||
{...southernTransportPreview}
|
||
/>
|
||
{actionButton(
|
||
'Cross-ship southern reserves',
|
||
southernTransportAction,
|
||
southernTransportPreview.unavailableReasons
|
||
.length > 0,
|
||
'southern-transport-quote',
|
||
)}
|
||
</>
|
||
)}
|
||
{returnTransportPreview && (
|
||
<>
|
||
<ShipmentQuote
|
||
id="return-transport-quote"
|
||
label="Return to reserves"
|
||
{...returnTransportPreview}
|
||
/>
|
||
{actionButton(
|
||
'Return to reserves',
|
||
returnTransportAction,
|
||
returnTransportPreview.unavailableReasons.length >
|
||
0,
|
||
'return-transport-quote',
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
{g.basicAlliedShipment && (
|
||
<p className="notice">
|
||
Move your new visitors out of {g.basicAlliedShipment.territory} before finishing.
|
||
Provisional Basic policy: finishing now sends {g.basicAlliedShipment.normal} normal
|
||
{' '}and {g.basicAlliedShipment.elite} special visiting forces to Tanks, not your ally’s forces.
|
||
{g.basicAlliedShipment.noField && ' Your No-Field will also be revealed; its newly placed forces will go to Tanks.'}
|
||
</p>
|
||
)}
|
||
<AdvancedAllySeparation quote={g.advancedAllySeparation ?? g.basicAllySeparation} basic={!g.advanced} />
|
||
{g.nexusGuildCunning?.offer && (
|
||
<section className="notice">
|
||
<p>Guild Cunning ends your ordinary shipment and movement, then offers one second shipment at normal Guild prices. Only an unused Hajr move can follow it. Provisional card combination: Ornithopter can replace that unused Hajr movement; declaring Cunning abandons any unfinished current card group.</p>
|
||
{g.nexusGuildCunning.offer.blocked && <p>{g.nexusGuildCunning.offer.blocked}</p>}
|
||
{actionButton('Finish ordinary turn and declare Guild Cunning',
|
||
guildCunningAction ?? {type: 'endMovement'}, !guildCunningAction)}
|
||
</section>
|
||
)}
|
||
{guildCunning && <p className="notice">{guildCunning.stage === 'secondShipment'
|
||
? 'Guild Cunning: choose your second shipment, or skip it and retain an unused Hajr move. This does not reopen ordinary movement.'
|
||
: guildCunning.stage === 'extraMove'
|
||
? 'The second shipment is settled. Use an unused Hajr move, replace it with Ornithopter, finish an active card group, or finish your turn.'
|
||
: 'Guild Cunning awaits its cancellation response.'}</p>}
|
||
{guildCunning?.stage === 'secondShipment' && actionButton('Skip second shipment',
|
||
nexusGuildSkipShipmentAction(g, guildCunning.event) ?? {type: 'endMovement'},
|
||
!nexusGuildSkipShipmentAction(g, guildCunning.event))}
|
||
{actionButton(guildCunning?.stage === 'secondShipment' ? 'Decline second shipment and finish' :
|
||
guildCunning ? 'Finish Guild turn' : 'Finish shipment & movement', {type: 'endMovement'}, false,
|
||
g.advancedAllySeparation?.territories.length ? 'advanced-ally-separation' :
|
||
g.basicAllySeparation?.territories.length ? 'basic-ally-separation' : undefined)}
|
||
</>
|
||
) : (
|
||
<p className="muted">
|
||
Waiting for {g.players.find((p) => p.id === g.active)?.name}{' '}
|
||
to ship and move.
|
||
</p>
|
||
))}
|
||
{g.phase === 6 && !g.battle && g.battleOrder && g.battleOrder.uses.length > 0 && (
|
||
<p className="notice">
|
||
Remaining battle-choice order: {g.battleOrder.remaining.map(id =>
|
||
g.players.find(player => player.id === id)?.name ?? id).join(' → ')}.
|
||
{' '}Only unresolved battles can be chosen. Each battle keeps its own aggressor and tie rules.
|
||
</p>
|
||
)}
|
||
{g.phase === 6 && !g.battle && g.active === me.id && (
|
||
<>
|
||
<p className="muted">
|
||
Choose one of your unresolved battles.
|
||
</p>
|
||
{botBattleChoices(g).map((battle) => (
|
||
<div
|
||
key={`${String(battle.territory)}-${String(battle.target)}`}
|
||
>
|
||
{actionButton(
|
||
`Fight ${g.players.find((p) => p.id === battle.target)?.name} · ${combatName(String(battle.territory))}`,
|
||
battle,
|
||
)}
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
{g.phase === 8 && me.faction === 'choam' && (
|
||
<>
|
||
<h3>Inflation</h3>
|
||
{g.inflationUsed ? (
|
||
<p className="muted">Your Inflation token has been used.</p>
|
||
) : g.inflationAttempted ? (
|
||
<p className="muted">
|
||
Placement was canceled. You may try again at a later
|
||
Mentat Pause.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<p className="muted">
|
||
Set next turn’s charity. The token flips at the next
|
||
Mentat Pause, then leaves the game at the following one.
|
||
You may save it by marking ready.
|
||
</p>
|
||
{actionButton('Double, then cancel', {
|
||
type: 'choamInflation',
|
||
side: 'double',
|
||
})}
|
||
{actionButton('Cancel, then double', {
|
||
type: 'choamInflation',
|
||
side: 'cancel',
|
||
})}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
{g.battle && (
|
||
<>
|
||
<p className="battle-location">
|
||
<Swords size={17} />
|
||
{g.battle.locationName ?? combatName(g.battle.territory)}
|
||
</p>
|
||
<p className="fine">
|
||
Aggressor: {g.players.find(p => p.id === g.battle!.aggressor)?.name}.
|
||
{' '}Tied battle: {g.players.find(p => p.id === g.battle!.tieWinner)?.name}
|
||
{g.battle.tieWinner !== g.battle.aggressor ? ' (Habbanya Stronghold advantage)' : ''}.
|
||
</p>
|
||
<EcazOccupyBattleSummary game={g} />
|
||
{g.battle.outcomeWindow && !g.battle.outcomeWindow.closed && (
|
||
<BattleOutcomeWindowControls window={g.battle.outcomeWindow} me={g.me}
|
||
players={g.players} busy={busy || !!g.decision || !!g.response || !!g.truthtrance || !!g.phaseOpening}
|
||
onReady={event => act({type: 'battleOutcomeReady', event})} />
|
||
)}
|
||
{!g.battle.revealed && g.battle.leaderless.length > 0 && (
|
||
<p className="notice block" role="status">
|
||
{g.battle.leaderless.map(id => g.players.find(p => p.id === id)?.name).join(', ')}
|
||
{' '}announced a Battle Plan without a leader or Cheap Hero.
|
||
No battle cards can be played; other plan choices remain sealed.
|
||
</p>
|
||
)}
|
||
{!!g.battle.native && (
|
||
<p className="notice">
|
||
Only the native faction may reveal Traitors or Face
|
||
Dancers here.
|
||
{[g.battle.attacker, g.battle.defender].includes(
|
||
g.battle.native,
|
||
) && (
|
||
<>
|
||
{' '}
|
||
{
|
||
g.players.find((p) => p.id === g.battle?.native)
|
||
?.name
|
||
}{' '}
|
||
adds +{g.battle.nativeBattleStrength} native strength
|
||
in ordinary strength comparisons, separately from the
|
||
dial.
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
{g.battle.eliteBlocked.length > 0 && (
|
||
<p className="notice">
|
||
Karama:{' '}
|
||
{g.battle.eliteBlocked
|
||
.map((id) => g.players.find((p) => p.id === id)?.name)
|
||
.join(', ')}{' '}
|
||
counts elite tokens as ordinary forces in this battle.
|
||
</p>
|
||
)}
|
||
{g.battle.fremenSupportBlocked && (
|
||
<p className="notice">
|
||
Karama: Fremen must support tokens with spice for full
|
||
strength in this battle.
|
||
</p>
|
||
)}
|
||
<NexusInspectionHistory game={g} />
|
||
{(!g.battle.preLeader || g.battle.preLeader.closed) && (
|
||
<BattlePromises
|
||
act={act}
|
||
game={g}
|
||
busy={busy}
|
||
fill={fillBattlePlan}
|
||
/>
|
||
)}
|
||
{g.battle.preLeader && !g.battle.preLeader.closed ? (
|
||
<BattleLeaderOpportunity game={g} act={act} busy={busy} />
|
||
) : g.battle.preparation ? (
|
||
<BattlePreparation
|
||
key={`${g.battle.event}-${g.battle.preparation.kind}-${g.battle.preparation.kind === 'nexusPrescienceAnswer' ? g.battle.nexusInspection?.field : g.battle.prescience?.field ?? ''}`}
|
||
game={g}
|
||
send={send}
|
||
busy={busy}
|
||
/>
|
||
) : g.battle.fullPlan &&
|
||
!g.battle.submitted.includes(g.battle.fullPlan.target) &&
|
||
g.battle.fullPlan.target !== me.id ? (
|
||
<p className="notice">
|
||
Waiting for{' '}
|
||
{
|
||
g.players.find(
|
||
(p) => p.id === g.battle!.fullPlan!.target,
|
||
)?.name
|
||
}{' '}
|
||
to commit the plan requested by Atreides.
|
||
</p>
|
||
) : [g.battle.attacker, g.battle.defender].includes(me.id) ? (
|
||
g.battle.revealed ? (
|
||
<>
|
||
{g.battle.smugglerCollection && <p className="notice">
|
||
{g.players.find(p => p.id === g.battle!.smugglerCollection!.player)?.name}’s Smuggler has {g.battle.smugglerCollection.amount} spice pending from the reveal-time piles.
|
||
A surviving leader collects unless an actual zero-strength weapon effect resolves. Collection settles before other battle cleanup, even after defeat; a partial collection across several piles requires its owner’s sector choice. This reveal-time cap is not yet spendable.
|
||
</p>}
|
||
{!g.battle.traitorVoters.includes(me.id) && (
|
||
<p className="notice">
|
||
Only the native faction may declare Traitors here.
|
||
Wait for that decision; any legal invader late defense
|
||
follows through its own original control.
|
||
</p>
|
||
)}
|
||
{g.battle.traitorVoters.includes(me.id) && !g.battle.traitorSubmitted.includes(me.id) && (
|
||
<>
|
||
<TraitorDecisionPromises game={g} />
|
||
{actionButton(
|
||
'Reveal traitor',
|
||
{
|
||
type: 'traitorCall',
|
||
call: true,
|
||
},
|
||
!canRevealTraitor,
|
||
)}
|
||
{!canRevealTraitor && (
|
||
<p className="fine">{noTraitorReason}</p>
|
||
)}
|
||
{actionButton('No traitor — resolve', {
|
||
type: 'traitorCall',
|
||
call: false,
|
||
}, !canDeclineTraitor)}
|
||
</>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<LeaderSkillBattleGuide game={g} leader={committed.leader ? String(committed.leader.value ?? '') : leader} />
|
||
{smugglerPlanReason && <p className="notice">{smugglerPlanReason}</p>}
|
||
{g.battle.insight && (
|
||
<p className="notice">
|
||
{committed[g.battle.insight.field]
|
||
? 'Your committed element'
|
||
: 'Prescience'}
|
||
: {g.battle.insight.field} ={' '}
|
||
{g.battle.insight.label}
|
||
</p>
|
||
)}
|
||
{g.battle.voice && (
|
||
<p className="notice">
|
||
Voice:{' '}
|
||
{g.battle.voice.must ? 'must play' : 'cannot play'}{' '}
|
||
{battleCardLabel(g.battle.voice.kind)}.
|
||
</p>
|
||
)}
|
||
{!g.battle.submitted.includes(me.id) ? (
|
||
<>
|
||
<label htmlFor="forces-dialed">
|
||
{occupyProfile ? 'Total forces dialed' : 'Forces dialed'} <HelpTip topic="dial" />
|
||
{me.faction === 'ixians' && (
|
||
<HelpTip topic="ixForces" />
|
||
)}
|
||
</label>
|
||
<BattleWheel
|
||
id="forces-dialed"
|
||
min={battleDialMinimum}
|
||
sliderLabel={occupyProfile ? 'Total forces dialed slider' : 'Forces dialed slider'}
|
||
max={battleDialMaximum}
|
||
step={battleDialStep}
|
||
value={
|
||
committed.dial
|
||
? Number(committed.dial?.value)
|
||
: battleDial
|
||
}
|
||
disabled={busy || !!committed.dial}
|
||
onChange={setDial}
|
||
/>
|
||
<EcazOccupyDialExplanation game={g}
|
||
dial={committed.dial ? Number(committed.dial.value) : battleDial}
|
||
support={battleFreeSupport ? 0 : battleSupport} />
|
||
<p className="fine">
|
||
{g.battle.noFieldPlayers.includes(me.id)
|
||
? g.battle.mixedNoField
|
||
? `Provisional ${g.advanced ? 'Advanced' : 'Basic'} mixed No-Field ${occupyProfile ? 'variable ' : ''}pool: ${battleForces} fighters, combining forces already here with the token's reserve-limited materialization. ${occupyProfile ? 'Fixed Ecaz strength is separate. ' : ''}This estimate is private; the mixed-pool formula is not an adjudicated publisher ruling.`
|
||
: `${battleForceOwnerName}’s concealed No-Field can materialize ${battleForces} physical forces from current reserves. This estimate is private.`
|
||
: occupyProfile
|
||
? `${battleForceOwnerName} supplies ${battleForces} physical fighters for the variable dial.`
|
||
: `You have ${battleForces} fighting forces here.`}
|
||
</p>
|
||
{!!ownBattleForces?.temporaryElite && (
|
||
<p className="notice">
|
||
{ownBattleForces.temporaryElite} ordinary counters count as Sardaukar for this battle. Their losses are ordinary counters, not starred Sardaukar.
|
||
</p>
|
||
)}
|
||
{ownBattleForces?.normalFreeSupport && (
|
||
<p className="notice">
|
||
The variable army’s Suboids fight at full strength without spice support this turn. Spice support here applies only to Cyborgs.
|
||
</p>
|
||
)}
|
||
{ownBattleForces?.eliteFreeSupport && (
|
||
<p className="notice">
|
||
Salusa Secundus is at high population. The variable army’s
|
||
Sardaukar fight at their full current strength
|
||
without spice support; normal forces still
|
||
require support.
|
||
</p>
|
||
)}
|
||
{g.advanced && !battleFreeSupport && (
|
||
<label htmlFor="battle-spice">
|
||
Spice support
|
||
{strongholdBankSupport > 0 && (
|
||
<span className="fine">
|
||
{' '}
|
||
· Bank pays{' '}
|
||
{Math.min(
|
||
battleSupport,
|
||
strongholdBankSupport,
|
||
)}
|
||
; personal and allied payment{' '}
|
||
{Math.max(
|
||
0,
|
||
battleSupport - strongholdBankSupport,
|
||
)}
|
||
</span>
|
||
)}
|
||
<Input
|
||
id="battle-spice"
|
||
type="number"
|
||
min={0}
|
||
max={battleSupportMaximum}
|
||
value={battleSupport}
|
||
onChange={(e) =>
|
||
setSupport(Number(e.target.value))
|
||
}
|
||
/>
|
||
</label>
|
||
)}
|
||
{g.advanced && g.aid.available > 0 && (
|
||
<>
|
||
<p className="fine">
|
||
CHOAM has reserved {g.aid.available} spice for
|
||
your support. Leave its share blank to cover
|
||
only your shortfall.
|
||
</p>
|
||
<label htmlFor="battle-ally-payment">
|
||
CHOAM’s share of this support payment
|
||
<Input
|
||
id="battle-ally-payment"
|
||
type="number"
|
||
min={0}
|
||
max={Math.min(
|
||
battleSupport,
|
||
g.aid.available,
|
||
)}
|
||
value={allyPayment}
|
||
onChange={(e) =>
|
||
setAllyPayment(e.target.value)
|
||
}
|
||
/>
|
||
</label>
|
||
</>
|
||
)}
|
||
<label htmlFor="battle-leader">
|
||
Leader <HelpTip topic="leader" />
|
||
<select
|
||
id="battle-leader"
|
||
value={
|
||
committed.leader
|
||
? String(committed.leader?.value ?? '')
|
||
: leader
|
||
}
|
||
disabled={!!committed.leader}
|
||
onChange={(e) => setLeader(e.target.value)}
|
||
>
|
||
<option value="">No leader</option>
|
||
{me.leaders
|
||
.filter(
|
||
(l) =>
|
||
!l.dead &&
|
||
controlsLeader(me, l) &&
|
||
(!l.usedAt ||
|
||
l.usedAt === g.battle?.territory),
|
||
)
|
||
.map((l) => (
|
||
<option key={l.id} value={l.id}>
|
||
{l.name} · {leaderStrengthLabel(l)}
|
||
</option>
|
||
))}
|
||
{me.hand
|
||
?.filter((c) => c.kind === 'hero')
|
||
.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name} · 0
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<SpiceBankerControl
|
||
game={g}
|
||
selectedLeader={selectedBattleLeader}
|
||
ownSupport={ownBattleSupport}
|
||
value={battleBankerSpice}
|
||
onChange={setBankerSpice}
|
||
disabled={busy}
|
||
/>
|
||
{[
|
||
['Weapon', weapon, setWeapon],
|
||
['Defense', defense, setDefense],
|
||
].map(([name, v, fn]) => (
|
||
<label
|
||
key={String(name)}
|
||
htmlFor={`battle-${String(name).toLowerCase()}`}
|
||
>
|
||
{String(name)}{' '}
|
||
<HelpTip
|
||
topic={
|
||
name === 'Weapon' ? 'weapon' : 'defense'
|
||
}
|
||
/>
|
||
<select
|
||
id={`battle-${String(name).toLowerCase()}`}
|
||
value={
|
||
battlePlanCardValue(g, name === 'Weapon' ? 'weapon' : 'defense', v as string)
|
||
}
|
||
disabled={
|
||
!!committed[name === 'Weapon' ? 'weapon' : 'defense'] &&
|
||
committed[name === 'Weapon' ? 'weapon' : 'defense']!.value !== null
|
||
}
|
||
onChange={(e) =>
|
||
(fn as (s: string) => void)(e.target.value)
|
||
}
|
||
>
|
||
<option value="">None</option>
|
||
{battlePlanCardOptions(g, name === 'Weapon' ? 'weapon' : 'defense',
|
||
selectedBattleLeader, selectedWeaponId, selectedDefenseId)
|
||
.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}{name === 'Weapon' && planetologistSpecial(c)
|
||
? planetologistSpecialNeedsChoice(c) ? ' · normal or Planetologist role' : ' · Planetologist +2, discard after battle'
|
||
: ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
))}
|
||
{planetologistRoleChoice && (
|
||
<label className="my-3 flex items-start gap-3">
|
||
<input type="checkbox" checked={!!planetologistAlternate}
|
||
onChange={event => setPlanetologistDraft({ key: planetologistRoleKey, enabled: event.target.checked })} />
|
||
<span>Use {selectedBattleWeapon!.name} as Planetologist Special (+2 leader; no normal card effect)</span>
|
||
</label>
|
||
)}
|
||
{!(planetologistWeapon && selectedWeaponId === 'ecaz-harass-withdraw') &&
|
||
<HarassWithdrawGuide preview={g.battle.harassWithdraw} state={harassControl} />}
|
||
{g.homeworlds && g.battle.harassWithdraw && !(planetologistWeapon && selectedWeaponId === 'ecaz-harass-withdraw') && (
|
||
<p className="notice">
|
||
Homeworld timing: actual undialed counters
|
||
return to your native reserve Homeworlds, never to
|
||
the Tanks. In Advanced, Emperor normal forces return
|
||
to Kaitain and Sardaukar to Salusa; Basic retains
|
||
its original single primary World. Your original
|
||
dial/support stay committed across population
|
||
thresholds. Harass cannot be played on any own World.
|
||
</p>
|
||
)}
|
||
{g.battle.reinforcements && !(planetologistWeapon && selectedWeaponId === 'ecaz-reinforcements') && (
|
||
<p className="notice">
|
||
Reinforcements may occupy either card slot but is neither a weapon nor a defense.
|
||
It adds 2 to battle strength without changing the physical dial or spice support.
|
||
The cost is three reserve forces sent to the Tanks on any revealed outcome.
|
||
Available for that quote: {g.battle.reinforcements.normal} normal,{' '}
|
||
{g.battle.reinforcements.elite} elite.
|
||
The card is discarded even after a traitor call or explosion.
|
||
{g.battle.reinforcements.blocked && ` ${g.battle.reinforcements.blocked}`}
|
||
</p>
|
||
)}
|
||
{reinforcementsSelected && reinforcementsSourceChoices && (
|
||
<ReinforcementsSources game={g} choices={reinforcementsSourceChoices}
|
||
selected={reinforcementsSourceIndex} busy={busy}
|
||
onChange={index => setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index })} />
|
||
)}
|
||
{g.battle.reinforcements?.noField && !(planetologistWeapon && selectedWeaponId === 'ecaz-reinforcements') && <p className="fine">
|
||
Provisional native order: your No-Field first materializes{' '}
|
||
{g.battle.reinforcements.noField.materializes} forces, leaving{' '}
|
||
{g.battle.reinforcements.noField.reservesAfterReveal} own reserves.
|
||
Three must remain after that reveal for Reinforcements;
|
||
a later Harass return cannot fund or change its original cost.
|
||
</p>}
|
||
{reinforcementsSelected &&
|
||
harassSelected && (
|
||
<p className="fine">
|
||
Provisional combined timing: pay the original three-reserve
|
||
Reinforcements cost before Harass & Withdraw returns any
|
||
undialed forces. Returned reserves cannot fund or change that
|
||
cost. Both physical cards are discarded after use.
|
||
</p>
|
||
)}
|
||
{reinforcementsPlanReason && <p role="status" className="notice">{reinforcementsPlanReason}</p>}
|
||
{!battlePairValid && <p role="status" className="notice">Choose two compatible battle cards. A single physical card cannot fill both slots.</p>}
|
||
{selectedBattleWeapon && planetologistWeapon && (
|
||
<p className="notice">
|
||
{selectedBattleWeapon.name} adds 2 to the surviving Planetologist leader.
|
||
It makes no weapon attack and does not use its normal effect.
|
||
Discard it after this battle, even if you win.
|
||
</p>
|
||
)}
|
||
{g.expansions.includes('ix') &&
|
||
(selectedSpecialWeapon || selectedBattleWeapon?.kind === 'poisonTooth') && <p className="fine">
|
||
Provisional original Stone/Tooth order: Stone chooses
|
||
first, then uncopied Tooth. Mirror copies still choose
|
||
before their originals, as printed.
|
||
</p>}
|
||
{selectedSpecialWeapon && (
|
||
<div className="my-3 flex flex-col gap-3">
|
||
<p>
|
||
{mirrorSelected
|
||
? 'Mirror Weapon copies the opposing revealed weapon attack, not its physical card. If the copied attack is Stone Burner, it uses undialed physical tokens; any copied post-reveal choice is yours. A winning Mirror remains physical Mirror, even if it copies activated Poison Tooth or Artillery.'
|
||
: 'Stone Burner compares undialed physical tokens, not dialed strength. Its leader effect is chosen after both plans are revealed.'}
|
||
</p>
|
||
{reinforcementsSelected && (
|
||
<p className="fine">
|
||
Reinforcements still spends three original reserves.
|
||
Its +2 normal-score bonus cannot increase the undialed
|
||
physical tokens compared by original or copied Stone Burner.
|
||
</p>
|
||
)}
|
||
<CardInspector card={selectedSpecialWeapon} />
|
||
{stonePlanReason && (
|
||
<p className="notice" role="status">
|
||
{stonePlanReason}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
{me.leaders.some((l) => l.capturedBy) && (
|
||
<p className="fine">
|
||
{me.leaders
|
||
.filter((l) => l.capturedBy)
|
||
.map(
|
||
(l) =>
|
||
`${l.name}: ${l.capturedBy === me.id ? 'your captive' : 'captured by Harkonnen'}`,
|
||
)
|
||
.join(' · ')}
|
||
</p>
|
||
)}
|
||
{me.kwisatz && (
|
||
<div className="space-y-2"><label>
|
||
<input
|
||
type="checkbox"
|
||
checked={kwisatz}
|
||
onChange={(e) => setKwisatz(e.target.checked)}
|
||
disabled={
|
||
!me.kwisatz.active ||
|
||
me.kwisatz.dead ||
|
||
g.battle.kwisatzBlocked ||
|
||
(!!me.kwisatz.usedAt &&
|
||
me.kwisatz.usedAt !== g.battle.territory)
|
||
}
|
||
/>
|
||
Use Kwisatz Haderach (+2; prevents traitors)
|
||
</label>
|
||
<KwisatzInspector state={me.kwisatz} usedTerritoryName={me.kwisatz.usedAt ? combatName(me.kwisatz.usedAt) : undefined} battle={{ territory: g.battle.territory, blocked: g.battle.kwisatzBlocked }} />
|
||
</div>
|
||
)}
|
||
{g.battle.fullPlan?.target === me.id && (
|
||
<p className="notice">
|
||
This commits your complete plan for Atreides’
|
||
private inspection. Your opponent’s plan stays
|
||
hidden until the public reveal.
|
||
</p>
|
||
)}
|
||
{actionButton(
|
||
g.battle.fullPlan?.target === me.id
|
||
? 'Commit plan for inspection'
|
||
: 'Seal battle plan',
|
||
bindBattlePlanCommitments(g, {
|
||
type: 'battlePlan',
|
||
...(planetologistAlternate ? { planetologistSpecial: true } : {}),
|
||
...(battleBankerSpice
|
||
? { bankerSpice: battleBankerSpice }
|
||
: {}),
|
||
...(g.aid.available > 0 && allyPayment !== ''
|
||
? { allyPayment: Number(allyPayment) }
|
||
: {}),
|
||
support:
|
||
battleFreeSupport
|
||
? 0
|
||
: battleSupport,
|
||
kwisatz: selectedBattleKwisatz,
|
||
dial: battleDial,
|
||
leader,
|
||
weapon,
|
||
defense,
|
||
...(reinforcementsSelected && selectedReserveSource
|
||
? { reinforcementsSources: selectedReserveSource } : {}),
|
||
}),
|
||
!!occupyControl?.blocked || !!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !!reinforcementsPlanReason || !battlePairValid,
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="muted">
|
||
Your plan is sealed. Waiting for your opponent.
|
||
</p>
|
||
{!!g.battle.plans[me.id]?.bankerSpice && (
|
||
<p className="fine">
|
||
Spice Banker: {g.battle.plans[me.id].bankerSpice}{' '}
|
||
spice committed.
|
||
</p>
|
||
)}
|
||
{g.battle.plans[me.id]?.planetologistSpecial && (
|
||
<p className="fine">Planetologist alternate Special: +2 if the leader survives; no normal card effect.</p>
|
||
)}
|
||
{g.battle.plans[me.id]?.reinforcementsSources && (
|
||
<p className="fine">Sealed reserve fee: {reinforcementsSourceLabel(g, g.battle.plans[me.id].reinforcementsSources!)}</p>
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
)
|
||
) : g.battle.revealed &&
|
||
g.battle.traitorVoters.includes(me.id) ? (
|
||
<>
|
||
<p className="muted">
|
||
Your ally is fighting. You may reveal a traitor against
|
||
their opponent.
|
||
</p>
|
||
{!g.battle.traitorSubmitted.includes(me.id) ? (
|
||
<>
|
||
<TraitorDecisionPromises game={g} />
|
||
{actionButton(
|
||
'Reveal ally’s opponent as traitor',
|
||
{
|
||
type: 'traitorCall',
|
||
call: true,
|
||
},
|
||
!canRevealTraitor,
|
||
)}
|
||
{!canRevealTraitor && (
|
||
<p className="fine">{noTraitorReason}</p>
|
||
)}
|
||
{actionButton('Decline traitor support', {
|
||
type: 'traitorCall',
|
||
call: false,
|
||
}, !canDeclineTraitor)}
|
||
</>
|
||
) : (
|
||
<p className="muted">
|
||
Waiting for the other traitor decisions.
|
||
</p>
|
||
)}
|
||
</>
|
||
) : (
|
||
<p className="muted">
|
||
{g.battle.revealed
|
||
? 'The table is completing traitor decisions.'
|
||
: 'The combatants are preparing their plans.'}
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
{g.phase === 2 && g.truthCharityGuide && <p className="notice">Claim Charity before finishing your Truthtrance Yes.</p>}
|
||
{[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) && !(g.phase === 2 && g.charityOrder) &&
|
||
actionButton(
|
||
g.ready.includes(me.id)
|
||
? 'Waiting for the table'
|
||
: g.spiceWindow
|
||
? 'Accept spice blow'
|
||
: 'Ready for next phase',
|
||
{ type: 'ready' },
|
||
g.ready.includes(me.id) || (g.phase === 2 && !!g.truthCharityGuide),
|
||
)}
|
||
</>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
<RevealedBattle game={g} />
|
||
{g.inflation && (
|
||
<p className="notice" role="status">
|
||
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'}
|
||
</p>
|
||
)}
|
||
<PublicDeals game={g} act={(action) => { void send(action); }}
|
||
busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||
{!reactionOwnsControls && <Bribes game={g} act={act} busy={busy} />}
|
||
<TruthHistory game={g} />
|
||
<HarkonnenExchangeInspection game={g} />
|
||
<section className="player-console">
|
||
<nav className="console-tabs">
|
||
{(['hand', 'leaders', 'log', 'rules'] as const).map((p) => (
|
||
<button
|
||
aria-pressed={panel === p}
|
||
className={panel === p ? 'selected' : ''}
|
||
onClick={() => setPanel(p)}
|
||
key={p}
|
||
>
|
||
{p === 'hand'
|
||
? `Your private hand · ${me.hand?.length ?? 0} / ${me.handLimit}`
|
||
: p === 'leaders'
|
||
? 'Your leaders'
|
||
: p === 'log'
|
||
? 'Table chronicle'
|
||
: 'Rules coverage'}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
{panel === 'hand' && reactionOwnsControls ? (
|
||
<HandBrowser key={`${g.code}:${me.id}`} cards={me.hand ?? []} empty={<p className="muted">Your Treachery hand is empty.</p>}>
|
||
{(card) => (
|
||
<article className="treachery-card" key={card.id}>
|
||
<h3>{card.name}</h3>
|
||
<CardRules card={card} />
|
||
<CardInspector card={card} />
|
||
</article>
|
||
)}
|
||
</HandBrowser>
|
||
) : panel === 'hand' ? (
|
||
<>
|
||
<ChoamCashIn game={g} act={act} busy={busy} />
|
||
<RicheseSpecialKarama game={g} act={act} busy={busy} />
|
||
<EcazSpecialKarama quote={g.ecazSpecialKarama ?? null} act={act} busy={busy} />
|
||
<RicheseGift game={g} act={act} busy={busy} />
|
||
<Distrans game={g} act={act} busy={busy} />
|
||
<JuiceOfSapho game={g} act={act} busy={busy} />
|
||
<NullentropyBox game={g} act={act} busy={busy} />
|
||
<ResidualPoison game={g} act={act} busy={busy} />
|
||
<PortableSnooper game={g} act={act} busy={busy} />
|
||
<HandBrowser key={`${g.code}:${me.id}`} cards={me.hand ?? []} empty={
|
||
<p className="muted">
|
||
{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.'}
|
||
</p>
|
||
}>
|
||
{(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 (
|
||
<article
|
||
className={`treachery-card card-${richese ? presentation.role : c.kind}`}
|
||
key={c.id}
|
||
>
|
||
<span className="eyebrow">
|
||
{richese
|
||
? presentation.category
|
||
: battleCardLabel(c.kind)}
|
||
</span>
|
||
<h3>{c.name}</h3>
|
||
<CardRules card={c} />
|
||
<CardInspector card={c} />
|
||
{availability && !availability.available && (
|
||
<p
|
||
className="card-unavailable"
|
||
id={`card-unavailable-${c.id}`}
|
||
>
|
||
{availability.reason}
|
||
</p>
|
||
)}
|
||
{!availability &&
|
||
g.status === 'setup' &&
|
||
!g.response &&
|
||
c.effect !== 'truthtrance' && (
|
||
<p className="fine">
|
||
Finish setup before using this card.
|
||
</p>
|
||
)}
|
||
<span className="card-mark">
|
||
{richese
|
||
? presentation.role === 'weapon'
|
||
? '†'
|
||
: presentation.role === 'defense'
|
||
? '◇'
|
||
: '✧'
|
||
: c.kind === 'special'
|
||
? '✧'
|
||
: c.kind === 'shield' || c.kind === 'snooper'
|
||
? '◇'
|
||
: '†'}
|
||
</span>
|
||
{canUseAsTruthtranceRole(g, me, c) && (
|
||
<>
|
||
<p className="fine">
|
||
{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.
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
disabled={
|
||
busy ||
|
||
!!g.truthtrance ||
|
||
!['setup', 'playing'].includes(g.status)
|
||
}
|
||
onClick={() =>
|
||
act({
|
||
type: 'card',
|
||
card: c.id,
|
||
...(c.effect === 'karama'
|
||
? { shrineTruthtrance: [c.id] }
|
||
: {}),
|
||
})
|
||
}
|
||
>
|
||
{c.effect === 'karama'
|
||
? 'Use Karama as Truthtrance'
|
||
: 'Declare Truthtrance'}
|
||
</Button>
|
||
{(me.hand?.filter(
|
||
(other) => canUseAsTruthtranceRole(g, me, other),
|
||
).length ?? 0) > 1 && (
|
||
<Button
|
||
variant="outline"
|
||
disabled={
|
||
busy ||
|
||
!!g.truthtrance ||
|
||
!['setup', 'playing'].includes(g.status)
|
||
}
|
||
onClick={() =>
|
||
act({
|
||
type: 'card',
|
||
card: c.id,
|
||
cards: me
|
||
.hand!.filter(
|
||
(other) =>
|
||
canUseAsTruthtranceRole(g, me, other) &&
|
||
other.id !== c.id,
|
||
)
|
||
.map((other) => other.id),
|
||
shrineTruthtrance: me
|
||
.hand!.filter(
|
||
(other) =>
|
||
canUseAsTruthtranceRole(g, me, other) &&
|
||
other.effect === 'karama',
|
||
)
|
||
.map((other) => other.id),
|
||
})
|
||
}
|
||
>
|
||
Declare both Truthtrances
|
||
</Button>
|
||
)}
|
||
</>
|
||
)}
|
||
{c.effect === 'amal' && (
|
||
<p className="fine">
|
||
At a phase opening, every faction returns half its
|
||
available spice to the bank, rounded up. Play before
|
||
everyone passes.
|
||
</p>
|
||
)}
|
||
{c.effect === 'thumper' && (
|
||
<p className="fine">
|
||
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}
|
||
</p>
|
||
)}
|
||
{c.effect === 'ghola' && (
|
||
<>
|
||
{g.ghola.eliteBlock && (
|
||
<p className="fine">{g.ghola.eliteBlock}</p>
|
||
)}
|
||
{homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined }) && (
|
||
<p className="fine" aria-live="polite">
|
||
{homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined })}
|
||
</p>
|
||
)}
|
||
<label htmlFor={`ghola-${c.id}`}>
|
||
Forces to revive (or choose a leader)
|
||
<Input
|
||
id={`ghola-${c.id}`}
|
||
type="number"
|
||
min={1}
|
||
max={g.ghola.maxForces}
|
||
value={amount}
|
||
onChange={(e) => setAmount(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
{me.elites && (
|
||
<EliteCount
|
||
label={
|
||
me.faction === 'ixians'
|
||
? 'Cyborgs to revive'
|
||
: 'Elite forces to revive'
|
||
}
|
||
value={eliteAmount}
|
||
onChange={setEliteAmount}
|
||
max={Math.min(amount, g.ghola.eliteRemaining)}
|
||
/>
|
||
)}
|
||
<label>
|
||
Revival target
|
||
<select
|
||
value={leader}
|
||
onChange={(e) => setLeader(e.target.value)}
|
||
>
|
||
<option value="">Forces</option>
|
||
{g.ghola.leaders.map((l) => (
|
||
<option key={l.id} value={l.id}>
|
||
{l.name}{l.id === 'duke-vidal' ? ' · set aside after revival' : ''}
|
||
</option>
|
||
))}
|
||
{g.ghola.kwisatz && (
|
||
<option value="kwisatz">Kwisatz Haderach</option>
|
||
)}
|
||
</select>
|
||
</label>
|
||
</>
|
||
)}
|
||
{g.advanced &&
|
||
canActivateKarama(c) &&
|
||
!me.specialKaramaUsed &&
|
||
me.faction === 'harkonnen' &&
|
||
g.phase === 3 && (
|
||
<>
|
||
<p className="muted">
|
||
Once per game: take up to four unseen cards, inspect
|
||
them, then return the same number.
|
||
</p>
|
||
<label>
|
||
Choose a player
|
||
<select
|
||
value={target}
|
||
onChange={(e) => setTarget(e.target.value)}
|
||
>
|
||
<option value="">Choose hand</option>
|
||
{g.players
|
||
.filter(
|
||
(p) =>
|
||
p.id !== me.id && (p.handCount ?? 0) > 0,
|
||
)
|
||
.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name} · {p.handCount} cards
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label htmlFor={`exchange-count-${c.id}`}>
|
||
Cards to take
|
||
<Input
|
||
id={`exchange-count-${c.id}`}
|
||
type="number"
|
||
min={1}
|
||
max={4}
|
||
value={amount}
|
||
onChange={(e) =>
|
||
setAmount(Number(e.target.value))
|
||
}
|
||
/>
|
||
</label>
|
||
<HelpTip topic="specialKarama" />
|
||
{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 && (
|
||
<>
|
||
<p className="muted">
|
||
Once per game: summon Shai-Hulud in a sand
|
||
territory. Resolve protection now; Nexus and rides
|
||
follow the spice blow.
|
||
</p>
|
||
<label htmlFor={`summon-worm-${c.id}`}>
|
||
Sand territory
|
||
<select
|
||
id={`summon-worm-${c.id}`}
|
||
value={
|
||
TERRITORIES.some(
|
||
(t) => t.id === selected && t.type === 'sand',
|
||
)
|
||
? selected
|
||
: ''
|
||
}
|
||
onChange={(e) => {
|
||
if (e.target.value) choose(e.target.value);
|
||
}}
|
||
>
|
||
<option value="">Choose territory</option>
|
||
{TERRITORIES.filter((t) => t.type === 'sand').map(
|
||
(t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}
|
||
</option>
|
||
),
|
||
)}
|
||
</select>
|
||
</label>
|
||
<HelpTip topic="summonedWorm" />
|
||
{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 && (
|
||
<>
|
||
<p className="muted">
|
||
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.
|
||
</p>
|
||
{g.revival.sardaukarFreeBlock && <p className="fine">{g.revival.sardaukarFreeBlock}</p>}
|
||
<label htmlFor={`special-revive-${c.id}`}>
|
||
Forces to revive
|
||
<Input
|
||
id={`special-revive-${c.id}`}
|
||
type="number"
|
||
min={1}
|
||
max={Math.min(3, me.tanks - (g.revival.sardaukarFreeBlock ? (me.elites?.tanks ?? 0) : 0))}
|
||
value={amount}
|
||
onChange={(e) =>
|
||
setAmount(Number(e.target.value))
|
||
}
|
||
/>
|
||
</label>
|
||
{me.elites && (
|
||
<EliteCount
|
||
label="Sardaukar to revive"
|
||
value={eliteAmount}
|
||
min={Math.max(0, amount - (me.tanks - me.elites.tanks))}
|
||
max={g.revival.sardaukarFreeBlock ? 0 : Math.min(amount, me.elites.tanks)}
|
||
onChange={setEliteAmount}
|
||
/>
|
||
)}
|
||
<HelpTip topic="specialKarama" />
|
||
{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) => (
|
||
<div key={l.id}>
|
||
{actionButton(
|
||
`Revive ${l.name} with special Karama`,
|
||
{
|
||
type: 'card',
|
||
mode: 'special',
|
||
card: c.id,
|
||
leader: l.id,
|
||
},
|
||
)}
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
{c.effect === 'weather' && (
|
||
<label htmlFor="weather-distance">
|
||
Storm distance
|
||
<Input
|
||
id="weather-distance"
|
||
type="number"
|
||
min={0}
|
||
max={10}
|
||
value={weatherDistance}
|
||
onChange={(e) =>
|
||
setWeatherDistance(Number(e.target.value))
|
||
}
|
||
/>
|
||
</label>
|
||
)}
|
||
{canSpendKarama(c, 'shipment-price') &&
|
||
g.phase === 5 &&
|
||
g.active &&
|
||
(g.active === me.id ? shipmentAvailable : !g.players.find((p) => p.id === g.active)?.shipped) && (
|
||
<Button
|
||
variant="outline"
|
||
disabled={
|
||
busy ||
|
||
!!g.truthtrance ||
|
||
!!g.karamaShipping ||
|
||
!!g.response ||
|
||
!!g.decision
|
||
}
|
||
onClick={() =>
|
||
act({
|
||
type: 'card',
|
||
card: c.id,
|
||
mode: 'shipment',
|
||
target: g.active,
|
||
})
|
||
}
|
||
>
|
||
Guild rates for{' '}
|
||
{g.players.find((p) => p.id === g.active)?.name}
|
||
</Button>
|
||
)}
|
||
{(c.effect !== 'truthtrance' ||
|
||
canUseAsKaramaRole(g, me, c)) &&
|
||
(c.kind === 'special' ||
|
||
canUseAsKaramaRole(g, me, c)) && (
|
||
<Button
|
||
variant="outline"
|
||
aria-describedby={
|
||
availability && !availability.available
|
||
? `card-unavailable-${c.id}`
|
||
: undefined
|
||
}
|
||
disabled={
|
||
busy ||
|
||
!!g.truthtrance ||
|
||
(!!availability && !availability.available) ||
|
||
!!homeworldRevivalActionBlock(g, { type: 'card', card: c.id, amount, elite: eliteAmount, leader: leader || undefined }) ||
|
||
(g.status !== 'playing' &&
|
||
!(
|
||
g.status === 'setup' &&
|
||
g.response &&
|
||
canUseAsKaramaRole(g, me, c)
|
||
)) ||
|
||
(!!g.phaseOpening &&
|
||
(c.effect !== 'amal' ||
|
||
g.phaseOpening.passed.includes(me.id))) ||
|
||
(c.effect === 'amal' && !g.phaseOpening) ||
|
||
(c.effect === 'thumper' &&
|
||
(!g.beforeSpiceDraw ||
|
||
!!g.response ||
|
||
!!g.decision))
|
||
}
|
||
onClick={() =>
|
||
act({
|
||
type: 'card',
|
||
elite: eliteAmount,
|
||
card: c.id,
|
||
mode: canUseAsKaramaRole(g, me, c)
|
||
? g.response
|
||
? 'cancel'
|
||
: 'purchase'
|
||
: undefined,
|
||
amount:
|
||
c.effect === 'weather'
|
||
? weatherDistance
|
||
: c.effect === 'ghola'
|
||
? amount
|
||
: dial,
|
||
leader: leader || undefined,
|
||
})
|
||
}
|
||
>
|
||
{canUseAsKaramaRole(g, me, c)
|
||
? g.response
|
||
? 'Cancel this power'
|
||
: 'Take current auction card'
|
||
: 'Play card'}
|
||
</Button>
|
||
)}
|
||
</article>
|
||
);
|
||
}}
|
||
</HandBrowser>
|
||
{!!me.faceDancers?.length && (
|
||
<div className="notice">
|
||
<h3>Your Face Dancers</h3>
|
||
{me.faceDancers.map((c) => (
|
||
<div key={c.leader} className="traitor-identity">
|
||
<span>
|
||
{leaderName(c.leader)} ·{' '}
|
||
{c.revealed ? 'Revealed' : 'Secret'}
|
||
</span>
|
||
{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,
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{!!me.traitors?.length && (
|
||
<div className="traitor-list">
|
||
<span className="eyebrow">YOUR TRAITORS</span>
|
||
{me.traitors.map((id) => (
|
||
<div key={id} className="traitor-identity">
|
||
<span>{leaderName(id)}</span>
|
||
{inspectIdentity(id, 'traitor')}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : panel === 'leaders' ? (
|
||
<>
|
||
<p className="fine">
|
||
Inspect your leader identities here. Choose and commit a leader
|
||
through the battle controls when making a plan.
|
||
</p>
|
||
<div className="leader-roster">
|
||
{me.leaders.map((l) => (
|
||
<article key={l.id} className="leader-identity">
|
||
<LeaderPortrait
|
||
identityId={l.id}
|
||
name={l.name}
|
||
className="mx-auto size-20"
|
||
badge={
|
||
<>
|
||
<span className="sr-only">Leader strength </span>
|
||
{leaderStrengthLabel(l)}
|
||
</>
|
||
}
|
||
fallback={
|
||
<div className="leader-strength-disc">
|
||
<span className="sr-only">Leader strength </span>
|
||
{leaderStrengthLabel(l)}
|
||
</div>
|
||
}
|
||
/>
|
||
<p className="eyebrow">{faction(l.faction).name}</p>
|
||
<h3>{l.name}</h3>
|
||
<p>
|
||
{l.dead
|
||
? 'In the tanks'
|
||
: !controlsLeader(me, l)
|
||
? 'Controlled by another faction'
|
||
: l.usedAt
|
||
? 'Used in a battle this turn'
|
||
: 'Ready'}
|
||
</p>
|
||
<LeaderInspector
|
||
identity={{
|
||
id: l.id,
|
||
name: l.name,
|
||
factionName: faction(l.faction).name,
|
||
strength: leaderStrengthLabel(l),
|
||
}}
|
||
/>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : panel === 'log' ? (
|
||
<ol className="game-log" aria-live="polite">
|
||
{[...g.log].reverse().map((e) => (
|
||
<li key={e.seq}>
|
||
<span>{String(e.seq).padStart(3, '0')}</span>
|
||
<div className="min-w-0 space-y-2">
|
||
<p className="m-0">{e.text.replace(
|
||
/used Karama to cancel fremenMovement\.$/,
|
||
'used Karama to cancel Fremen movement.',
|
||
)}</p>
|
||
<StormCardLogInspector component={e.component} />
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
) : (
|
||
<div className="coverage-copy">
|
||
<h3>Rules verification in progress</h3>
|
||
<p>
|
||
This build implements a playable portion of the basic game. It is
|
||
not yet a fully rules-compliant edition.
|
||
</p>
|
||
<p>
|
||
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.
|
||
</p>
|
||
<a href="/rules">
|
||
Open the rules coverage ledger <ArrowRight size={14} />
|
||
</a>
|
||
</div>
|
||
)}
|
||
</section>
|
||
<VictoryContinuation key={g.victoryContinuation?.event} game={g} act={act} busy={reactionBusy} />
|
||
<VictoryProgress
|
||
progress={g.victoryProgress}
|
||
players={g.players}
|
||
fremen={g.fremenVictory}
|
||
ecazHomeworld={g.ecazHomeworldVictory}
|
||
/>
|
||
{g.strongholdCards && (
|
||
<details className="m-4 rounded-xl border border-[#a88b60] p-4">
|
||
<summary className="cursor-pointer text-lg">
|
||
Inspect Stronghold Cards
|
||
</summary>
|
||
<StrongholdCardGallery
|
||
state={g.strongholdCards}
|
||
players={g.players}
|
||
/>
|
||
</details>
|
||
)}
|
||
<footer>
|
||
<span>Unofficial fan implementation · Development build</span>
|
||
<span>Board geometry: Truthsayer / MIT</span>
|
||
</footer>
|
||
</main>
|
||
);
|
||
}
|