import { liveMovementExpressionPromises } from './movement-expression-promises'; import { isAlliedShipmentVisit } from './allied-separation'; import { discoveryBotActions } from './discovery-options'; import { normalAuctionHandBlock } from './normal-auction'; import { atreidesFullPlanAction } from './atreides-full-plan'; import { liveMovementPromises } from './movement-promises'; import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options'; import { quoteEcazOccupyDial } from './ecaz-occupy-battle'; import { recruitsPlayAction } from './recruits'; import type { Card } from './cards'; import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, planetologistSpecialNeedsChoice, leaderSkillBattleBonus } from './leader-skill-combat'; import { defaultSmugglerBattleAllocation, smugglerBattlePlanBlock } from './smuggler-battle'; import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots'; import { mirrorWeaponModeBlock } from './mirror-weapon-mode'; import { defaultHarassWithdrawAllocation, quoteHarassWithdraw } from './harass-withdraw'; import { leaderSkillStrongholdCount } from './leader-skill-battle-board'; import { discoveryFlightBotActions } from './discovery-flight-options'; import { discoveryEntryBotActions } from './discovery-entry-options'; import { discoveryStormActions } from './discovery-storm-options'; import { greatMakerBotActions } from './great-maker-options'; import { nexusChoamTradeBotActions } from './nexus-choam-trade-options'; import { nexusChoamBetrayalBotActions } from './nexus-choam-betrayal-options'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options'; import { nexusMoritaniBotActions } from './nexus-moritani-options'; import { moritaniBetrayalBotActions } from './nexus-moritani-betrayal-options'; import { ecazBetrayalBotActions } from './nexus-ecaz-betrayal-options'; import { fremenBetrayalBotActions } from './nexus-fremen-betrayal-options'; import { ecazInquiryBotActions } from './nexus-ecaz-inquiry-options'; import { nexusEcazDukeBotActions } from './nexus-ecaz-duke-options'; import { ecazBetrayalOffer } from './nexus-ecaz-betrayal'; import { fremenBetrayalOffer } from './nexus-fremen-betrayal'; import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup'; import { choamPowerAction, choamPowerBotPlay } from './choam-power-options'; import { nexusCardBotActions } from './nexus-card-options'; import { nexusTraitorBotActions } from './nexus-traitor-options'; import { nexusTleilaxuBotActions } from './nexus-tleilaxu-options'; import { nexusSuboidBotActions } from './nexus-suboid-options'; import { nexusAdvisorBotActions } from './nexus-advisor-options'; import { nexusSardaukarBotActions } from './nexus-sardaukar-options'; import { tupileIntelligenceActions } from './tupile-intelligence-options'; import { biddingEndActions, choamMarketPolicy } from './bidding-end-options'; import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options'; import { caladanReinforcementActions } from './caladan-reinforcement-options'; import { grummanCollectionActions } from './grumman-collection-options'; import { botHomeworldShipmentPaymentAllowed } from './homeworld-payment-options'; import { guildHomeworldShipmentActions } from './guild-homeworld-shipment-options'; import { choamSaleGholaTiming } from './choam-market-ghola'; import { junctionTransportActions } from './junction-transport-options'; import { currentJunctionOffer, junctionSponsor } from './junction-offer'; import { homeworldShipmentActions } from './homeworld-shipment-options'; import { isBoardNoFieldLocation, isHomeworldNoFieldLocation } from './richese-no-field'; import { emperorHomeworldMoveActions, withNativeShipmentSources, } from './homeworld-options'; import { matchesPlanClaim, respectsBattlePromises } from './battle-promises'; import { ecazOccupancyRelation } from './ecaz-occupy'; import { territoryEntryBlock } from './occupancy'; import { strongholdBattleEffects } from './stronghold-battle'; import { eliteRevivalRemaining, forceRevivalPrice, paidForceRevivalCost, } from './revival'; import { controlsLeader, nativeAvailable } from './leader-control'; import { quoteRicheseHomeworldForces, quoteRicheseHomeworldPair } from './richese-homeworld-shipment'; import { CHEAP_HERO_TRAITOR, matchingTraitor } from './traitors'; import { treacheryDeck, battleLeaderStrength, harvesterAvailable, } from './cards'; import { isWeaponCard, isStoneBurner, isMirrorWeapon, isDefenseCard, defaultVoiceMatch, playedVoiceMatch, weaponKills, battleWeaponsExplode, isShield, } from './battle-cards'; import { ownedTech } from './tech-tokens'; import type { StrongholdProgress } from './victory-progress'; import { canUseAsKarama } from './karama'; import { kullPurposeBlock, kullSpecialPurpose } from './kull-purpose'; import { canUseAsKaramaRole, shrineTruthtranceBotAction, } from './shrine'; import { groupGuildShipmentRate, reserveShipmentCost } from './shipment-price'; import { moritaniSpecialKaramaBotAction } from './moritani-special-karama-options'; import { quoteSmugglerShipment } from './smuggler-shipment'; import { quoteSmugglerNoField } from './smuggler-no-field'; import { spiceBankerBattleMaximum, spiceBankerModeSupported } from './spice-banker'; import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options'; import { nexusEmperorPurchaseAction, nexusEmperorRevivalAction } from './nexus-emperor-secret-ally-options'; import { nexusFremenRevivalAction } from './nexus-fremen-revival-options'; import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options'; import { liveShipmentPromises, matchesShipment } from './shipment-promises'; import { botEntryAllowed, botGroundMoveAllowed, groundMoveRouteDistance, fremenReserveEntry, guildTransportCost, } from './bot-mobility'; import { planetologistMovementModeSupported, planetologistLeader, planetologistGatherAdvisorEntry } from './planetologist-movement'; import { botPlanetologistFlightActions } from './bot-planetologist-flight'; import { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection, sandmasterLeader } from './sandmaster-movement'; import { sandmasterWormCollection, sandmasterCunningCollection } from './sandmaster-worm'; import { fremenCunningPublicDestinationBlock } from './nexus-fremen-cunning'; import { richeseCardDefinition } from './richese-cards'; import { presenceAt } from './force-presence'; import { botArrivalBlock, botWormRideArrivalBlock } from './bot-arrival'; import { botSemutaActions } from './bot-semuta'; import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kull'; import { botNexusRicheseBetrayalActions } from './bot-nexus-richese-betrayal'; import { botNexusGuildBetrayalActions } from './bot-nexus-guild-betrayal'; import { botNexusIxianBetrayalActions } from './bot-nexus-ixian-betrayal'; import { botNexusHarkonnenBetrayalActions } from './bot-nexus-harkonnen-betrayal'; import { validateCohortSelection, type OrnithopterMode } from './ornithopter'; import { fighterCount, isAdvisor } from './advisors'; import { quoteAdvisorMatching, AdvisorMatchingError } from './advisor-matching'; import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from './advisor-ambassador-matching'; import { guildTransportQuote } from './transport-quote'; import { guildNoFieldTransportActions } from './bot-guild-no-field-transport'; import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from './advisor-gather-matching'; import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat'; import { reinforcementsBattleForces } from './reinforcements-homeworld'; import type { NativeReserveSelections } from './homeworld-native-reserves'; import { stoneBurnerPlanBlock, stoneBurnerComparison, stoneBurnerWithdrawalContext } from './stone-burner'; import { applyAction, normalizeAutomaticGame, RuleError, viewGame, type Action, type Game, type GameView, type Plan, } from './engine'; import { TERRITORIES, gameTerritories, gameDistance, MOBILE_LOCATION, MOBILE_STRONGHOLD, mobileRoutes, splitLocation, location, territory, } from './board'; import { DIFFICULTIES } from './bot-profiles'; import { botBattleChoices } from './bot-battle-choices'; const countAt = (p: GameView['players'][number], t: string) => Object.entries(p.forces) .filter(([key]) => splitLocation(key).territory === t) .reduce((n, [, count]) => n + count, 0); const rank = (g: GameView) => { const me = g.players.find((p) => p.id === g.me)!; return DIFFICULTIES.indexOf(me.bot ?? me.autopilot ?? 'Medium'); }; const variation = (g: GameView, salt: string) => { let n = 2166136261; for (const c of `${g.code}-${g.me}-${g.turn}-${g.phase}-${salt}`) n = Math.imul(n ^ c.charCodeAt(0), 16777619); return (n >>> 0) / 4294967296; }; function alliedNoFieldOffer(g: GameView): Action | null { const me = g.players.find((p) => p.id === g.me)!; const ally = g.players.find(p => p.id === me.ally); const level = rank(g); const alliedNoField = g.richeseNoField; if ( alliedNoField?.owner === me.id && alliedNoField.canOfferAlly && !alliedNoField.allyDeclined && alliedNoField.private && alliedNoField.allyPricing && ally ) { const inventory = alliedNoField.private; const pricing = alliedNoField.allyPricing; const oldMarker = inventory.deployed; const oldValue = inventory.tokens.find((token) => token.id === oldMarker?.tokenId) ?.value ?? 0; // Do not spend an ally's unprojected spice or erase an empty bluff to offer aid. if (!oldMarker || Math.min(oldValue, me.reserves) > 0) { const preferred = level === 0 ? 3 : 5; const token = [...inventory.tokens] .filter( (token) => token.value > 0 && token.id !== inventory.lastShipped, ) .sort( (a, b) => Math.abs(a.value - preferred) - Math.abs(b.value - preferred), )[0]; const targets = TERRITORIES.flatMap((t) => t.sectors .filter((sector) => sector !== g.storm) .map((sector) => ({ t, sector, score: (t.type === 'stronghold' ? 20 : 0) + (g.spice[location(t.id, sector)] ?? 0) + (level === 0 ? variation(g, location(t.id, sector)) : 0), })), ) .filter( ({ t }) => g.players.every((p) => presenceAt(p, t.id) === 0) && !g.moritaniTerror?.tokens.some( (token) => token.status === 'placed' && token.location === t.id, ) && !g.ambassadors?.tokens.some( (token) => token.zone === 'placed' && token.location === t.id, ), ) .sort((a, b) => b.score - a.score); if (token) { const target = targets.find( ({ t }) => (t.type === 'stronghold' ? pricing.stronghold : pricing.other) <= (me.spice ?? 0), ); if (target) return { type: 'offerRicheseNoField', token: token.id, event: alliedNoField.event, territory: target.t.id, sector: target.sector, payer: me.id, }; } } } return null; } function technologyCardValue( g: GameView, card: { id: string; kind: string; effect?: string }, ): number { if (rank(g) === 0) return variation(g, card.id); if (card.kind === 'worthless') return 0; if (card.effect === 'karama') return 10; if (['shield', 'snooper', 'shieldSnooper'].includes(card.kind)) return 8; if ( ['projectile', 'poison', 'poisonBlade', 'lasgun', 'poisonTooth'].includes( card.kind, ) ) return 7; if (card.kind === 'hero') return 2; return 5; } function closeToStrongholdVictory(row: StrongholdProgress | undefined) { return ( !!row && (row.strongholds.length + Number(row.techStronghold) >= row.target - 1 || (row.occupyTarget !== null && row.jointlyOccupied.length >= row.occupyTarget - 1)) ); } function destinations(g: GameView, allowStorm = false) { const me = g.players.find((p) => p.id === g.me)!; const level = rank(g); const rivals = g.players.filter((p) => p.id !== me.id && p.id !== me.ally); const ownProgress = g.victoryProgress?.find((row) => row.player === me.id); return gameTerritories(g) .flatMap((t) => t.sectors .filter((s) => s === 0 || s !== g.storm || allowStorm) .map((s) => { const key = location(t.id, s); const enemy = rivals.reduce((n, p) => n + fighterCount(p, t.id), 0); const enemyOwner = rivals.find((p) => fighterCount(p, t.id)); const enemyThreat = closeToStrongholdVictory( g.victoryProgress?.find((row) => row.player === enemyOwner?.id), ); const joinsEcazAlly = ownProgress?.occupyTarget !== null && ownProgress?.occupyTarget !== undefined && t.type === 'stronghold' && !fighterCount(me, t.id) && g.players.some((p) => p.id === me.ally && fighterCount(p, t.id)); const allyBlocked = me.ally && g.players.some((p) => p.id === me.ally && presenceAt(p, t.id)) && ecazOccupancyRelation(g.players, me.id, me.ally, { kind: 'territory', id: t.id, }) !== 'ecazAlliance'; const score = level === 0 ? variation(g, key) * 15 : (t.type === 'stronghold' ? 18 : 0) + (g.spice[key] ?? 0) * ((me.spice ?? 0) < 5 ? 2 : 0.7) - enemy * (level === 1 ? 2 : 1) + (enemyOwner && ownedTech(g.techTokens, enemyOwner.id).length ? ownedTech(g.techTokens, me.id).length === 2 ? 14 : 4 : 0) - (presenceAt(me, t.id) ? 10 : 0) + (level === 3 && enemyThreat && t.type === 'stronghold' ? 22 : 0) + (level >= 2 && joinsEcazAlly ? ownProgress!.jointlyOccupied.length >= 2 ? 30 : 14 : 0) - (t.type === 'polar' ? 15 : 0); return { t: t.id, s, key, enemy, score: allyBlocked && t.type !== 'polar' ? -1000 : score, }; }), ) .sort((a, b) => b.score - a.score); } function guildAmbassadorShipments(g: GameView): Action[] { const entry = g.ambassadorEntry; if (entry?.stage !== 'ship' || !entry.shipment || entry.beneficiary !== g.me) return []; const me = g.players.find((p) => p.id === g.me)!; const descriptor = entry.shipment; const level = rank(g); const available = Math.min(descriptor.maximum, me.reserves); const destinations = descriptor.destinations.filter((d) => !d.blocked); if (available < 1 || !destinations.length) return [{ type: 'decision', event: entry.event, amount: 0 }]; const score = (d: (typeof destinations)[number]) => { const key = location(d.territory, d.sector); if (level === 0) return variation(g, key); const enemy = g.players .filter((p) => p.id !== me.id && p.id !== me.ally) .reduce((n, p) => n + fighterCount(p, d.territory), 0); return ( (territory(d.territory).type === 'stronghold' ? 20 : 0) + (g.spice[key] ?? 0) - enemy * 2 - (presenceAt(me, d.territory) ? 8 : 0) - (d.advisors ? 3 : 0) ); }; destinations.sort((a, b) => score(b) - score(a)); const destination = destinations[0]; const amount = Math.min(available, destination.maximum ?? available); const minimum = Math.max(0, amount - (me.reserves - descriptor.eliteReserves)); const elite = level === 0 ? minimum : Math.min(amount, descriptor.eliteReserves); return [ { type: 'decision', event: entry.event, amount, elite, territory: destination.territory, sector: destination.sector, }, ]; } function fremenAmbassadorMoves(g: GameView): Action[] { const entry = g.ambassadorEntry; if (entry?.stage !== 'move' || !entry.movement || entry.beneficiary !== g.me) return []; const me = g.players.find((p) => p.id === g.me)!; const level = rank(g); const candidates: { action: Action; score: number }[] = []; for (const source of entry.movement.sources) { for (const destination of source.destinations.filter((d) => !d.blocked)) { const target = location(destination.territory, destination.sector); const sectors = source.sectors.filter((s) => s.key !== target); const physical = sectors.reduce((n, s) => n + s.forces, 0); const marker = !physical && source.marker && location(source.territory, source.marker.sector) !== target ? source.marker : null; if (!physical && !marker) continue; const keepsSource = source.territory !== destination.territory && territory(source.territory).type === 'stronghold' && physical > 1 && presenceAt(me, source.territory) === physical; let remaining = Math.min( physical - (keepsSource ? 1 : 0), destination.maximum ?? physical, ); const forces: Record = {}; const eliteForces: Record = {}; for (const s of sectors) { const n = Math.min(remaining, s.forces); if (!n) continue; remaining -= n; forces[s.key] = n; eliteForces[s.key] = level === 0 ? Math.max(0, n - (s.forces - s.elites)) : Math.min(n, s.elites); } const amount = Object.values(forces).reduce((n, value) => n + value, 0) + Number(!!marker); const enemy = g.players .filter((p) => p.id !== me.id && p.id !== me.ally) .reduce((n, p) => n + fighterCount(p, destination.territory), 0); const abandons = source.territory !== destination.territory && territory(source.territory).type === 'stronghold' && presenceAt(me, source.territory) === amount; const score = level === 0 ? variation(g, `${source.territory}:${target}`) : (territory(destination.territory).type === 'stronghold' ? 20 : 0) + (g.spice[target] ?? 0) - enemy * 2 + Math.min(amount - enemy, 4) - (presenceAt(me, destination.territory) ? 8 : 0) - (abandons ? 25 : 0) - (destination.advisors ? 3 : 0); const sandmaster = !marker ? sandmasterDestinationCollection(g, me.id, source.territory, destination.territory, destination.sector) : null; try{ quoteAdvisorAmbassadorMatching({...g,pendingAmbassador:{...entry,turn:g.turn,phase:g.phase, beneficiary:me.id},advisorAmbassadorMatchingDenied:entry.matchingDenied}, {parent:entry.event,owner:me.id,origin:source.territory,destination:destination.territory, sector:destination.sector,advisors:destination.advisors,group:forces}); }catch(error){if(!(error instanceof AdvisorAmbassadorMatchingError))throw error;continue;} candidates.push({ score, action: { type: 'decision', event: entry.event, forces, eliteForces, territory: destination.territory, sector: destination.sector, ...(sandmaster?.blocked === null ? { sandmasterCollect: true, sandmasterPile: sandmaster.key } : {}), ...(marker ? { noField: { tokenId: marker.tokenId, event: marker.event } } : {}), }, }); } } candidates.sort((a, b) => b.score - a.score); return candidates.length ? [candidates[0].action] : []; } function ornithopterMoves(g: GameView): Action[] { const flight = g.ornithopter; if (!flight || flight.blocked) return []; const me = g.players.find((p) => p.id === g.me)!; const level = rank(g), active = flight.active, cohort = active?.cohort; const noField = g.richeseNoField?.owner === me.id ? g.richeseNoField : null; const deployed = noField?.private?.deployed; const marker = deployed && isBoardNoFieldLocation(deployed.location) ? { ...deployed, location: deployed.location } : null; const markerSource = marker ? location(marker.location.territory, marker.location.sector) : null; const permittedMarker = marker && !g.homeworldMobility?.noFieldMovementBlocked && (!cohort || (cohort.noField?.tokenId === marker.tokenId && cohort.noField.event === noField?.event && cohort.noField.from === markerSource)); const sources = Object.entries(me.forces) .map(([from, n]) => { const currentElite = me.elites?.forces[from] ?? 0; const elite = Math.min( currentElite, cohort ? (cohort.elites[from] ?? 0) : currentElite, ); const normal = Math.min( n - currentElite, cohort ? (cohort.forces[from] ?? 0) - (cohort.elites[from] ?? 0) : n - currentElite, ); return { from, normal, elite }; }) .filter((source) => source.normal + source.elite > 0); const total = sources.reduce((n, s) => n + s.normal + s.elite, 0) + (permittedMarker ? 1 : 0); const modes: OrnithopterMode[] = active ? [active.mode] : level === 0 ? ['range3', 'twoGroups'] : ['twoGroups', 'range3']; const result: Action[] = []; const targets = destinations(g).filter( (to) => to.t !== MOBILE_STRONGHOLD && to.score > -1000 && to.t !== marker?.location.territory, ); for (const mode of modes) { if ( !active && (flight.modes.find((option) => option.mode === mode)?.blocked || (mode === 'twoGroups' && total < 2)) ) continue; const candidates = [ ...sources.map((source) => ({ ...source, marker: false })), ...(permittedMarker && markerSource ? [{ from: markerSource, normal: 0, elite: 0, marker: true }] : []), ]; for (const to of targets) for (const source of candidates) { const origin = splitLocation(source.from); if ( source.from === to.key || origin.sector === g.storm || !botEntryAllowed(g, me, to.t, to.s, 'move', origin.territory) || (origin.territory !== to.t && g.balisetRestrictions.some( (b) => b.player === me.id && b.territory === to.t, ) && g.players.some( (p) => p.faction === 'choam' && countAt(p, to.t) > 0, )) ) continue; if (source.marker && (to.enemy > 0 || countAt(me, to.t) > 0)) continue; let amount = source.normal + source.elite; if (!active && mode === 'twoGroups' && amount === total) amount = Math.ceil(amount / 2); const elite = Math.min( source.elite, level === 0 ? Math.max(0, amount - source.normal) : amount, ); const normalRange = (fighterCount(me, 'arrakeen') || fighterCount(me, 'carthag') ? 3 : (me.faction === 'fremen' && !me.fremenMovementBlocked) || (me.faction === 'ixians' && elite > 0 && !me.ixMovementBlocked) ? 2 : 1) + (me.faction === 'choam' ? g.choamMovementBonus : 0); const distance = groundMoveRouteDistance(g, me, source.from, to.key); if ( distance > (mode === 'range3' ? 3 : normalRange) || (!active && mode === 'range3' && distance <= normalRange) ) continue; const action: Action = source.marker ? { type: 'move', forces: {}, noField: marker!.tokenId, event: noField!.event, territory: to.t, sector: to.s, } : { type: 'move', from: source.from, amount, elite, territory: to.t, sector: to.s, }; if (cohort) { try { validateCohortSelection( cohort, me.forces, me.elites?.forces ?? {}, source.marker ? {} : { [source.from]: amount }, source.marker ? {} : { [source.from]: elite }, source.marker ? { tokenId: marker!.tokenId, event: noField!.event, from: source.from, } : undefined, ); } catch { continue; } } result.push({ ...action, ...(active ? { ornithopterEvent: active.event } : { movementCard: flight.card.id, ornithopter: mode }), }); } } return result; } /** Route collection is optional; retain the original legal candidate alongside * its existing deterministic route/pile choice, without tuning destination rank. */ function sandmasterMovementCandidates(game: GameView, actions: Action[]): Action[] { if (!sandmasterLeader(game, game.me)) return actions; const seen = new Set(); return actions.flatMap(action => { const choice = action.sandmaster === undefined ? sandmasterDefaultChoice(game, game.me, action) : null; const candidates = choice?.collect.length ? [{ ...action, sandmaster: choice }, action] : [action]; return candidates.filter(candidate => { const key = JSON.stringify(candidate); if (seen.has(key)) return false; seen.add(key); return true; }); }); } function candidatePlan(a: Action): Plan { const plan: Plan = { ...(Number(a.bankerSpice ?? 0) ? { bankerSpice: Number(a.bankerSpice) } : {}), dial: Number(a.dial), support: Number(a.support ?? 0), leader: typeof a.leader === 'string' ? a.leader : null, weapon: typeof a.weapon === 'string' ? a.weapon : null, defense: typeof a.defense === 'string' ? a.defense : null, kwisatz: a.kwisatz === true, }; if (a.reinforcementsSources !== undefined) plan.reinforcementsSources = a.reinforcementsSources as NativeReserveSelections; return plan; } function plans(g: GameView): Action[] { const me = g.players.find((p) => p.id === g.me)!; const b = g.battle!; const other = g.players.find( (p) => p.id === (b.attacker === me.id ? b.defender : b.attacker), )!; const level = rank(g); const actual = countAt(me, b.territory); const elite = Object.entries(me.elites?.forces ?? {}) .filter(([key]) => splitLocation(key).territory === b.territory) .reduce((sum, [, n]) => sum + n, 0); const forces: CombatForces = b.ownForces ?? { normal: actual - elite, normalFixedHalf: me.faction === 'ixians', elite, eliteStrength: (!g.advanced && me.faction !== 'ixians') || (b.eliteBlocked.includes(me.id) && !(g.advanced && me.faction === 'ixians')) || (me.faction === 'emperor' && other.faction === 'fremen') ? 1 : 2, freeSupport: !g.advanced || (me.faction === 'fremen' && !b.fremenSupportBlocked), }; const own = forces.normal + forces.elite; const occupyProfile = ecazOccupyOwnProfile(g); const occupyDials = ecazOccupyDialChoices(g); const typedForces = g.advanced || me.faction === 'ixians'; const ownStrength = occupyProfile?.maxDial ?? (typedForces ? maxCombatDial(forces) : own); type SupportChoice = { support: number; cost: number }; const supportCache = new Map(); const supporting = (dial: number, pool = forces, cache = supportCache) => { if (cache.has(dial)) return cache.get(dial)!; const choices = Array.from( { length: (g.advanced ? Math.min( maxCombatSupport(pool), (me.spice ?? 0) + g.aid.available + (b.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0), ) : 0) + 1, }, (_, support) => (occupyProfile ? occupyDials.some((option) => option.dial === dial && option.support === support) ? quoteEcazOccupyDial(occupyProfile, dial, support).options : [] : casualtyOptions(pool, dial, support)).map((loss) => ({ support, cost: Math.max( 0, support - (b.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0), ) * (level === 0 ? 3 : 0.7) + loss.normal + loss.elite * 2.5, })), ) .flat() .sort((a, b) => a.cost - b.cost); const result = choices[0] ?? null; cache.set(dial, result); return result; }; const reservePools = b.reinforcements?.homeworldSources?.map(sources => { const pool = b.native === me.id ? reinforcementsBattleForces(forces, sources[b.territory]) : forces; return { sources, pool, cache: pool === forces ? supportCache : new Map() }; }); // Include affordable bank-supported dials even when the estimated winning // dial exceeds the budget; otherwise a large army can miss its free support. const bankSupportedDials = g.advanced && b.strongholdEffects[me.id] === 'arrakeen' ? Array.from( { length: Math.floor(ownStrength * 2) + 1 }, (_, i) => i / 2, ).filter((dial) => (supporting(dial)?.support ?? 0) > 0) : []; const freeEliteDials = forces.eliteFreeSupport ? Array.from( { length: Math.floor(ownStrength * 2) + 1 }, (_, i) => i / 2, ).filter((dial) => casualtyOptions(forces, dial, 0).length > 0) : []; const enemies = b.opponentForces ? b.opponentForces.normal + b.opponentForces.elite : presenceAt(other, b.territory); const ownNativeBonus = b.native === me.id ? b.nativeBattleStrength : 0; const enemyNativeBonus = b.native === other.id ? b.nativeBattleStrength : 0; const enemyCapacity = b.opponentForces ? maxCombatDial(b.opponentForces) : enemies; const insights = [ ...(b.insight && b.prescience?.player === me.id ? [b.insight] : []), ...(b.nexusInspection?.owner === me.id ? b.nexusInsights.filter((insight) => insight.active) : []), ]; const known = (field: string) => insights.find((insight) => insight.field === field); const knownCards = treacheryDeck(['ix']); const revealedWeapon = knownCards.find((c) => c.id === known('weapon')?.value); const revealedDefense = knownCards.find((c) => c.id === known('defense')?.value); const inspected = b.fullPlanInsight?.target === other.id ? b.fullPlanInsight : null; const enemyWeapon = inspected?.cards.find( (c) => c.id === inspected.plan.weapon, ); const enemyDefense = inspected?.cards.find( (c) => c.id === inspected.plan.defense, ); const commitments = b.ownCommitments.filter( (element) => element.target === me.id, ); const fixed = (field: BattlePlanInspectionField, value: string | number | null) => commitments.every( (element) => element.field !== field || fixedBattleInspectionMatches(field, element.value, value, me.hand?.find(card => card.id === value)), ); const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? []; const bankerPrototypeAvailable = spiceBankerModeSupported(g); const specialForLeader = (card: Card | undefined, leader: string | null) => canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card}); const prototypeCardAllowed = (card: Card, slot: 'weapon' | 'defense') => { const native = (!isMirrorWeapon(card) || !mirrorWeaponModeBlock(g)) && (card.id !== 'ecaz-reinforcements' || (!!b.reinforcements && !b.reinforcements.blocked)) && (card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked)); return native || (slot === 'weapon' && planetologistSpecialNeedsChoice(card) && me.leaders.some(l => !l.dead && controlsLeader(me, l) && (!l.usedAt || l.usedAt === b.territory) && specialForLeader(card, l.id))); }; const weapons = [ null, ...(me.hand ?? []).filter(c => prototypeCardAllowed(c, 'weapon') && (battleCardSlotEligible('weapon', c) || me.leaders.some(l => !l.dead && controlsLeader(me, l) && (!l.usedAt || l.usedAt === b.territory) && specialForLeader(c, l.id)))).map(c => c.id), ].filter((id) => fixed('weapon', id)); const defenses = [ null, ...(me.hand ?? []).filter(c => prototypeCardAllowed(c, 'defense') && battleCardSlotEligible('defense', c)).map((c) => c.id), ].filter((id) => fixed('defense', id)); const availableLeaders = me.leaders.filter( (l) => !l.dead && controlsLeader(me, l) && !battleSkills.some(a => a.leader === l.id && a.faceUp && !a.captured) && (!l.usedAt || l.usedAt === b.territory), ); const heroes = (me.hand ?? []).filter((c) => c.kind === 'hero'); const voice = b.voice?.target === me.id ? b.voice : null; const mayOmitLeader = !availableLeaders.length && !heroes.some(hero => !(voice && !voice.must && playedVoiceMatch(hero, 'leader', voice.kind))); const leaders = [ ...(mayOmitLeader ? [null] : []), ...availableLeaders.map((l) => l.id), ...heroes.map((c) => c.id), ].filter((id) => fixed('leader', id)); const voiceApplies = !!voice && (availableLeaders.length > 0 || heroes.length > 0) && (me.hand ?? []).some((c) => defaultVoiceMatch(c, voice.kind)); const knownEnemyLeader = g.allLeaders.find((l) => l.id === known('leader')?.value); const expectedLeader = inspected ? (g.allLeaders.find((l) => l.id === inspected.plan.leader)?.strength ?? 0) + (inspected.plan.kwisatz ? 2 : 0) : (knownEnemyLeader?.strength ?? (level >= 2 ? Math.max( 0, ...other.leaders .filter((l) => !l.dead && controlsLeader(other, l)) .map((l) => l.strength), ) : 3)); const expectedDial = inspected ? inspected.plan.dial : known('dial') ? Number(known('dial')!.value) : Math.ceil(enemyCapacity * [0.25, 0.4, 0.6, 0.75][level]); const options: { action: Action; score: number }[] = []; for (const leader of leaders) for (const weapon of weapons) for (const defense of defenses) { const kwisatz = !!leader && !!me.kwisatz?.active && !me.kwisatz.dead && !b.kwisatzBlocked && (!me.kwisatz.usedAt || me.kwisatz.usedAt === b.territory); const myLeader = me.leaders.find((l) => l.id === leader); const enemyDisc = inspected ? g.allLeaders.find((l) => l.id === inspected.plan.leader) : knownEnemyLeader; const expectedEnemyStrength = inspected || knownEnemyLeader ? battleLeaderStrength(enemyDisc, myLeader) + (inspected?.plan.kwisatz ? 2 : 0) : level >= 2 && other.leaders.some( (l) => l.id === 'tleilaxu-0' && !l.dead && controlsLeader(other, l), ) ? Math.max(expectedLeader, myLeader?.strength ?? 0) : expectedLeader; const strength = battleLeaderStrength( me.leaders.find((l) => l.id === leader), inspected ? g.allLeaders.find((l) => l.id === inspected.plan.leader) : { strength: expectedLeader }, ) + (kwisatz ? 2 : 0); const w = me.hand?.find((c) => c.id === weapon); const d = me.hand?.find((c) => c.id === defense); if (b.smugglerCollectionEnabled && smugglerBattlePlanBlock({ assignments: battleSkills, leader: myLeader, weapon: w, defense: d, kwisatz }, b.smugglerCollectionSupported, { territory: b.territory, spice: g.spice })) continue; if (!battleCardSlotEligible('weapon', w, {planetologistWeapon: specialForLeader(w, leader)})) continue; const stoneBattle = isStoneBurner(w) || (inspected && isStoneBurner(enemyWeapon)); if ((!leader && (weapon || defense)) || !validBattleSlotPair(w, d, specialForLeader(w,leader))) continue; const planetologistSpecial = planetologistSpecialNeedsChoice(w) && specialForLeader(w, leader); const reinforcements = (weapon === 'ecaz-reinforcements' && !planetologistSpecial) || defense === 'ecaz-reinforcements'; const harass = (weapon === 'ecaz-harass-withdraw' && !planetologistSpecial) || defense === 'ecaz-harass-withdraw'; if (reinforcements && (!b.reinforcements || b.reinforcements.blocked)) continue; if (harass && (!b.harassWithdraw || b.harassWithdraw.blocked)) continue; if (voice) { const used = playedVoiceMatch( me.hand?.find((c) => c.id === leader), 'leader', voice.kind, ) || playedVoiceMatch(w, 'weapon', voice.kind) || playedVoiceMatch(d, 'defense', voice.kind); if (voice.must ? voiceApplies && !used : used) continue; } const safeLeader = kwisatz || !!matchingTraitor( me.traitors ?? [], leader, me.hand?.find((c) => c.id === leader), ); const traitorHolders = [ other, ...g.players.filter( (p) => p.faction === 'harkonnen' && p.ally === other.id, ), ].filter((holder) => !b.native || holder.id === b.native); const exposedLeader = !kwisatz && traitorHolders.some((p) => matchingTraitor( p.revealedTraitors, leader, me.hand?.find((c) => c.id === leader), ), ); const effects = strongholdBattleEffects( w, d, inspected ? enemyWeapon : undefined, inspected ? enemyDefense : undefined, true, true, b.strongholdEffects[me.id], b.strongholdEffects[other.id], ); const ownSurvivingStrength = effects.stunned || effects.attackerDead ? 0 : strength + leaderSkillBattleBonus({ assignments:battleSkills, selectedLeader:leader ? {id:leader,kind:myLeader?'disc':'hero'} : undefined, weapon:w,defense:d,skilledLeaderSurvives:true, planetologistSpecial: planetologistSpecial ? true : undefined, }).bonus; const enemySurvivingStrength = effects.stunned || (inspected && effects.defenderDead) ? 0 : expectedEnemyStrength + (inspected?.plan.bankerSpice ?? 0); const opponentPenalty = stoneBattle ? 0 : bureaucratBattlePenalty( battleSkills, myLeader?.id, !effects.attackerDead, leaderSkillStrongholdCount(g, other), ); const rawIdeal = stoneBattle ? 0 : level === 0 ? Math.floor(variation(g, `${leader}-${weapon}`) * (own + 1)) : Math.min( ownStrength, Math.max( 0, enemySurvivingStrength - opponentPenalty + expectedDial - ownSurvivingStrength + enemyNativeBonus - ownNativeBonus + (b.tieWinner === me.id ? 0 : typedForces ? 0.5 : 1), ), ); // Untyped forces dial whole counters even against a half-strength opponent. const ideal = typedForces ? rawIdeal : Math.min(ownStrength, Math.ceil(rawIdeal)); for (const dial of new Set([ ideal, ...bankSupportedDials, ...freeEliteDials, ...occupyDials.map((option) => option.dial), ...[1, 2, 3].map((bonus) => Math.max(0, ideal - bonus)), 0, ...(typedForces ? [0.5] : []), Math.max(0, ownStrength - 1), ownStrength, ])) { const action: Action = { type: 'battlePlan', ...(planetologistSpecial ? { planetologistSpecial: true } : {}), kwisatz, dial, leader, weapon, defense, }; const committedDial = commitments.find( (element) => element.field === 'dial', ); if (committedDial) action.dial = committedDial.value; if (!fixed('dial', Number(action.dial))) continue; let commitment = typedForces && !(reinforcements && g.homeworlds) ? supporting(Number(action.dial)) : null; if (reinforcements && g.homeworlds) { if (!reservePools?.length) continue; let selectedSources: NativeReserveSelections | undefined; let selectedCommitment: SupportChoice | null = null; for (const variant of reservePools) { if (!typedForces && Number(action.dial) > variant.pool.normal + variant.pool.elite) continue; const supported = typedForces ? supporting(Number(action.dial), variant.pool, variant.cache) : null; if (typedForces && !supported) continue; if (selectedSources && (selectedCommitment?.cost ?? 0) <= (supported?.cost ?? 0)) continue; selectedSources = variant.sources; selectedCommitment = supported; } if (!selectedSources) continue; action.reinforcementsSources = selectedSources; commitment = selectedCommitment; } if (typedForces && !commitment) continue; if (commitment) action.support = commitment.support; const supportCost = Math.max( 0, Number(action.support ?? 0) - (b.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0), ); const ordinaryAllyPayment = Math.max( 0, supportCost - (me.spice ?? 0), ); const ownSupportCost = Math.max( 0, supportCost - ordinaryAllyPayment, ); const bankerSpice = Math.min( level + 1, 3, bankerPrototypeAvailable ? spiceBankerBattleMaximum( battleSkills, myLeader?.id, me.spice ?? 0, ownSupportCost, ) : 0, ); if (bankerSpice) action.bankerSpice = bankerSpice; const stonePlan = isStoneBurner(me.hand?.find((c) => c.id === action.weapon)) || isMirrorWeapon(me.hand?.find((c) => c.id === action.weapon)); if (stonePlan && !action.leader) continue; const stoneContext = stonePlan && harass ? stoneBurnerWithdrawalContext(b.stoneBurnerContext?.occupy, b.attacker === me.id ? 'attacker' : 'defender', b.harassWithdraw?.occupy) : b.stoneBurnerContext?.occupy; if ( stonePlan && (!b.stoneBurnerContext || b.stoneBurnerContext.blocked || !b.stoneBurnerContext.opponentPools.length || b.stoneBurnerContext.opponentPools.some((pool) => stoneBurnerPlanBlock( forces, Number(action.dial), Number(action.support ?? 0), pool, b.attacker === me.id ? 'attacker' : 'defender', b.tieWinner === b.attacker ? 'attacker' : 'defender', stoneContext, b.stoneBurnerContext?.allowAllocations, ), )) ) continue; let withdrawn = 0; if (harass) { if (!b.harassWithdraw) continue; try { const allocation = b.harassWithdraw.allowAllocation ? defaultHarassWithdrawAllocation(b.harassWithdraw, Number(action.dial), Number(action.support ?? 0)) : undefined; const quote = quoteHarassWithdraw(b.harassWithdraw, Number(action.dial), Number(action.support ?? 0), allocation); withdrawn = quote.returned.normal + quote.returned.elite; } catch { continue; } } const effectiveWeapon = w && isWeaponCard(w) && w.kind !== 'worthless' ? 6 : 0; const effectiveDefense = d && isDefenseCard(d) && d.kind !== 'worthless' ? 4 : 0; const selfExplosion = battleWeaponsExplode(w, d); const correctDefense = inspected ? weaponKills(enemyWeapon) && !weaponKills(enemyWeapon, d) : !!revealedWeapon && weaponKills(revealedWeapon) && !weaponKills(revealedWeapon, d); const explosionRisk = inspected ? battleWeaponsExplode(w, d, enemyWeapon, enemyDefense) : (isShield(revealedDefense) && w?.kind === 'lasgun') || (revealedWeapon?.kind === 'lasgun' && isShield(d)); const reinforcementBonus = reinforcements && !stoneBattle && !selfExplosion && !explosionRisk ? 2 : 0; const undialed = stoneBattle ? Math.min( ...casualtyOptions( forces, Number(action.dial), Number(action.support ?? 0), ).map((loss) => own - loss.normal - loss.elite), ) : 0; const enemyUndialed = stoneBattle ? Math.max( 0, ...(b.stoneBurnerContext?.opponentPools ?? []).flatMap( (pool) => inspected ? casualtyOptions( pool, inspected.plan.dial, inspected.plan.support, ).map( (loss) => pool.normal + pool.elite - loss.normal - loss.elite, ) : [pool.normal + pool.elite], ), ) : 0; const score = stoneBattle ? (undialed - enemyUndialed) * 6 + undialed * 2 + (undialed > enemyUndialed || (undialed === enemyUndialed && b.tieWinner === me.id) ? 30 : -30) - Number(action.dial) * 2 + (stonePlan ? 6 : 0) + (effectiveDefense ? 2 : 0) : level === 0 ? variation(g, `${leader}-${weapon}-${defense}-${dial}`) * 100 : (ownSurvivingStrength + reinforcementBonus + (!effects.attackerDead && !effects.stunned ? bankerSpice : 0)) * 2 + opponentPenalty * 2 + (inspected && enemySurvivingStrength === 0 ? 10 : 0) + effectiveWeapon + effectiveDefense + (safeLeader && level >= 2 ? 5 : 0) - Math.abs( Number(action.dial) - Math.max(0, ideal - bankerSpice), ) * 2 + (correctDefense ? 10 : 0) - (selfExplosion || explosionRisk ? 80 : 0) - (exposedLeader ? 100 : 0); options.push({ action, score: score - (commitment?.cost ?? 0) * 0.2 - bankerSpice * 0.2 + (harass && level > 0 && !exposedLeader ? (ownSurvivingStrength + Number(action.dial) + bankerSpice + ownNativeBonus < enemySurvivingStrength - opponentPenalty + expectedDial + enemyNativeBonus ? withdrawn * (1 + level * 0.5) : -10) : 0), }); } } const ranked = options .sort((a, b) => b.score - a.score) .map((x) => x.action) .filter((a) => respectsBattlePromises( b.truthPromises, me.id, candidatePlan(a), me.hand ?? [], ), ); if (b.compliantPlan && commitments.every( (element) => fixed(element.field, b.compliantPlan![element.field]), )) { const action: Action = { type: 'battlePlan', ...b.compliantPlan }; if ((g.mixedBattlePromises ?? []).some(promise => promise.player === me.id && promise.turn === g.turn && promise.battle === b.event && !promise.fulfilled && !promise.released)) ranked.unshift(action); else ranked.push(action); } return ecazOccupyPolicyActions(g, ranked); } // This policy receives a personalized view, never the authoritative decks or rival hands. function gamontAction(g: GameView): Action | undefined { const me = g.players.find((p) => p.id === g.me)!; const play = choamPowerBotPlay(g, 'gamont', (card) => technologyCardValue(g, card)); if (!play || g.phase !== 8) return; const options = g .choamWorthless!.gamont.filter((o) => o.target !== me.ally) .map((o) => { const owner = g.players.find((p) => p.id === o.target)!; const t = gameTerritories(g).find( (t) => t.id === splitLocation(o.key).territory, )!; const count = countAt(owner, t.id); const threatenedWin = closeToStrongholdVictory( g.victoryProgress?.find((row) => row.player === owner.id), ); const score = rank(g) === 0 ? variation(g, o.key + o.target) * 10 : (t.type === 'stronghold' && count === 1 ? 30 + (threatenedWin ? 30 : 0) : 0) + o.elite * 8 + (fighterCount(owner, t.id) ? 2 : 0); return { ...o, score }; }) .sort((a, b) => b.score - a.score); const choice = options[0]; if (choice) return choamPowerAction(g, play, { target: choice.target, from: choice.key, elite: choice.elite, }) ?? undefined; } /** Supported, server-derived timing only; no rival hand or sealed bid is inspected. */ function saphoAction(g: GameView): Action | null { const me = g.players.find((p) => p.id === g.me)!; const card = me.hand?.find( (c) => richeseCardDefinition(c)?.card.effect === 'juiceOfSapho', ); const options = g.saphoOptions; if (!card || !options?.length) return null; const earlySpice = me.reserves >= 3 && (me.faction === 'fremen' || (me.spice ?? 0) >= 3) && Object.values(g.spice).some((amount) => amount >= 6); const battleFirst = options.some(option => option.scope === 'battleOrder') && (g.battleChoices ?? []).some(battle => [battle.attacker, battle.defender].includes(me.id) && gameTerritories(g).some(t => t.id === battle.territory && t.type === 'stronghold')); const preferred = (options[0].scope === 'movement' && earlySpice) || battleFirst ? 'first' : 'last'; const option = options.find((candidate) => candidate.mode === preferred) ?? options[0]; return { type: 'card', card: card.id, scope: option.scope, event: option.event, mode: option.mode }; } function tupileCleanupActions(g: GameView): Action[] { const me = g.players.find((player) => player.id === g.me); const offer = g.homeworldTupileCleanup; if (!me || !(me.bot ?? me.autopilot) || g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed || g.automaticContinuationPending || g.response || g.truthtrance || !offer || offer.blocked || offer.player !== g.me || g.decision?.kind !== 'homeworldTupileCleanup' || g.decision.player !== g.me || g.decision.event !== offer.event || !Number.isSafeInteger(offer.excess) || offer.excess <= 0) return []; const eligible = new Set(offer.eligibleCards); const seen = new Set(); const cards = (me.hand ?? []).filter((card) => { if (!eligible.has(card.id) || seen.has(card.id)) return false; seen.add(card.id); return true; }).sort((a, b) => technologyCardValue(g, a) - technologyCardValue(g, b) || a.id.localeCompare(b.id)); if (cards.length < offer.excess) return []; return [{ type: 'decision', event: offer.event, cards: cards.slice(0, offer.excess).map((card) => card.id) }]; } function policyActions(g: GameView): Action[] { const me = g.players.find((p) => p.id === g.me)!; const level = rank(g); if (!(me.bot ?? me.autopilot) || g.status === 'finished') return []; if (g.status === 'lobby') return me.ready ? [] : [{ type: 'ready' }]; if (g.victoryContinuation) return g.victoryContinuation.actor === me.id ? [{ type: 'victoryContinuation', event: g.victoryContinuation.event, mode: 'finish' }] : []; if (g.homeworldTupileBlocked) return []; const dukeGrant = g.homeworldEcazDuke; if (dukeGrant && dukeGrant.occupier === me.id && !dukeGrant.blocked && dukeGrant.event && dukeGrant.recipients.length) return [{ type: 'homeworldDuke', event: dukeGrant.event, recipient: dukeGrant.recipients[0].id }]; if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup) return tupileCleanupActions(g); if (!g.decision && !g.response && g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked) return []; // Resolve these original source choices before unrelated optional card plays. // Private faces and eligible recipients come only from the personalized view. if (g.decision?.kind === 'homeworldOccupiedPercentage') { const offer = g.homeworldOccupiedPercentage; return g.decision.player === me.id && offer && offer.owner === me.id && offer.event === g.decision.event && !offer.blocked ? [{ type: 'decision', event: offer.event, ownAmount: offer.maxOwnAmount }] : []; } if (g.decision?.kind === 'homeworldOccupiedBonus') { const offer = g.homeworldOccupiedBonus; const recipient = offer?.recipients.find((player) => player.player === me.id) ?? offer?.recipients[0]; return g.decision.player === me.id && offer && offer.owner === me.id && offer.event === g.decision.event && !offer.blocked && recipient ? [{ type: 'decision', event: offer.event, recipient: recipient.player }] : []; } if (g.decision?.kind === 'ixAuction') { if (g.decision.player !== me.id || g.ixTechnology?.blocked) return []; const cards = [...(g.ixTechnology?.pool ?? [])].sort( (a, b) => technologyCardValue(g, a) - technologyCardValue(g, b), ); return cards[0] ? [{ type: 'decision', card: cards[0].id, position: 'bottom' }] : []; } if (g.decision?.kind === 'richeseCacheTerms') { const bidding = g.richeseBidding; return g.decision.player === me.id && bidding?.owner === me.id && !bidding.offerBlocked ? [{ type: 'decision', event: bidding.event, method: 'onceAround', direction: 'clockwise' }] : []; } if (g.decision?.kind === 'richeseCache') { const bidding = g.richeseBidding; if (bidding && bidding.owner !== me.id && !bidding.cacheTerms) return []; if (g.decision.player !== me.id || !bidding || bidding.offerBlocked) return []; const cards = [...(bidding.cache ?? [])].sort( (a, b) => technologyCardValue(g, a) - technologyCardValue(g, b), ); return cards[0] ? [{ type: 'decision', event: bidding.event, card: cards[0].id, ...(bidding.owner === me.id ? { method: level === 0 ? 'silent' as const : 'onceAround' as const, direction: 'clockwise' } : {}), }] : []; } if (g.decision?.kind === 'nullentropy') { if (g.decision.player !== me.id) return []; const search = g.nullentropy?.search; if (!search) return []; const ordered = [...search.cards].sort((a, b) => { const score = (card: typeof a) => { const richese = richeseCardDefinition(card); const unfinished = richese && ![ 'karama', 'distrans', 'ornithopter', 'residualPoison', 'portableSnooper', 'stoneBurner', 'juiceOfSapho', ].includes(richese.card.effect); return unfinished ? -100 : technologyCardValue(g, card); }; return score(b) - score(a); }); return ordered[0] ? [{ type: 'decision', event: search.event, card: ordered[0].id }] : []; } if (g.truthCharityGuide) return [g.truthCharityGuide.action]; if (g.bribePromiseGuide) return [g.bribePromiseGuide.action]; if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action]; if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action]; if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action]; if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action]; if (g.richeseBidPromiseGuide) return [g.richeseBidPromiseGuide.action]; if (g.phaseBidPromiseGuide) return [g.phaseBidPromiseGuide.action]; if (g.bidPromiseGuide) return [g.bidPromiseGuide.action]; if (g.truthtrance) { const w = g.truthtrance; if (w.stage === 'order' && w.order) return w.order.holders.includes(me.id) && !w.order.passed.includes(me.id) ? [{type:'truthOrderPass',event:w.order.event,revision:w.order.revision}] : []; if (w.stage === 'priority') return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }]; if (w.stage === 'answer') { if (w.question?.target !== me.id) return []; if (w.question.kind === 'mixedBattlePlan') { const answer = g.truthMixedBattleAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'mixedBid') { const answer = g.truthMixedBidAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'mixedRevival') { const answer = g.truthMixedRevivalAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'revivalExpression') { const answer = g.truthRevivalExpressionAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'leaderRevival') { const answer = g.truthLeaderRevivalAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'allianceExpression' || w.question.kind === 'mixedAlliance') { const choices = w.question.kind === 'allianceExpression' ? g.truthAllianceExpressionAnswers : g.truthMixedAllianceAnswers; const answer = choices?.find(answer => answer !== 'unknown') ?? choices?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'alliance') { const answer = g.truthAllianceAnswers?.find(answer => answer !== 'unknown') ?? g.truthAllianceAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'mixedBribe') { const answer = g.truthMixedBribeAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'bribeExpression') { const answer = g.truthBribeExpressionAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'bribe') { const answer = g.truthBribeAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'revival') { const answer = g.truthRevivalAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'bid') { const answer = g.truthBidAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'charity') { const answer = g.truthCharityAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'richeseBid') { const answer = g.truthRicheseBidAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'earlyRicheseBid') { const answer = g.truthEarlyRicheseBidAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'phaseBid') { const answer = g.truthPhaseBidAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'turnActions') { const answer = g.truthTurnActionAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'travel') { const answer = g.truthTravelAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'mixedTraitorCall') { const answer = g.truthMixedTraitorAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'traitorCall') { const answers = g.truthTraitorAnswers ?? []; const answer = answers.find(answer => answer === 'yes') ?? answers[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'mixedMovement') { const answer = g.truthMixedMovementAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'movementExpression') { const answer = g.truthMovementExpressionAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'movement') { const answer = g.truthMovementAnswers?.find(answer => answer !== 'unknown') ?? g.truthMovementAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } if (w.question.kind === 'mixedShipment') { const choices = g.truthShipmentAnswers ?? []; const answer = choices.find(choice => choice === 'yes' || choice === 'no') ?? choices[0]; return answer ? [{type:'truthAnswer',answer}] : []; } if (w.question.kind === 'shipment') { const question = w.question; const choices = g.truthShipmentAnswers ?? []; const reserveBudget = Math.min(20, Math.max(0, me.reserves - [0, 1, 2, 3][level])); // This only ranks the server's feasible answers. It never proves // impossibility or assumes a hidden hand, future ally funds or a branch. const preferYes = gameTerritories(g).some(destination => { const spice = Object.entries(g.spice) .filter(([key]) => splitLocation(key).territory === destination.id) .reduce((sum, [, amount]) => sum + amount, 0); for (let amount = 1; amount <= reserveBudget; amount++) if ((destination.type === 'stronghold' || spice >= amount) && matchesShipment(question, { territory: destination.id, amount })) return true; return false; }); const preferred = preferYes ? 'yes' : 'no'; const answer = choices.includes(preferred) ? preferred : (choices.find((choice) => choice === 'yes' || choice === 'no') ?? choices[0] ?? 'unknown'); return [{ type: 'truthAnswer', answer }]; } if (w.question.kind === 'futureBattleLeader') { const choices = g.truthFutureBattleLeaderAnswers ?? ['unknown']; return [{ type: 'truthAnswer', answer: choices[0] }]; } if (w.question.kind === 'battlePlan') { const choices = g.truthBattleAnswers ?? ['unknown']; const claim = w.question.claim; const preferred = plans(g) .map((plan) => matchesPlanClaim(claim, candidatePlan(plan), me.hand ?? []) ? 'yes' : 'no', ) .find((answer) => choices.includes(answer)); return [{ type: 'truthAnswer', answer: preferred ?? choices[0] }]; } return [{ type: 'truthAnswer', answer: g.truthAnswer ?? 'unknown' }]; } if (w.queue[0]?.player !== me.id) return []; if (w.stage === 'unknown') return [{ type: 'truthSave' }]; const other = g.players.find( (p) => p.id !== me.id && (p.id === g.battle?.attacker || p.id === g.battle?.defender), ) ?? g.players.find((p) => p.id !== me.id)!; const leader = [...me.leaders] .filter((l) => !l.dead) .sort((a, b) => b.strength - a.strength)[0]; return [ { type: 'truthAsk', question: { kind: 'fact', target: other.id, fact: leader ? { kind: 'traitor', leader: leader.id } : { kind: 'hand', name: 'Shield' }, }, }, ]; } if (g.status === 'setup' && !g.decision) { const choosingTraitors = !g.setupStage || g.setupStage === 'traitors'; const placingForces = !g.setupStage || g.setupStage === 'forces'; const makingPrediction = !g.setupStage || g.setupStage === 'prediction'; if (choosingTraitors && me.traitorChoices?.length) { const choices = [...me.traitorChoices].sort((a, b) => { const score = (id: string) => { if (id === CHEAP_HERO_TRAITOR) return level === 0 ? variation(g, id) : 4; const l = g.allLeaders.find((l) => l.id === id)!; return level === 0 ? variation(g, id) : l.strength + (l.faction === me.faction ? -5 : 0); }; return score(b) - score(a); }); return [{ type: 'traitor', leader: choices[0] }]; } if (placingForces && me.faction === 'fremen' && me.reserves === 20) return [ { type: 'fremenSetup', placements: { sietch_tabr: level === 0 ? 10 : 7, false_wall_south: level === 0 ? 0 : 3, false_wall_west: 0, }, }, ]; if (placingForces && me.faction === 'ecaz' && me.reserves === 20 && g.setupStage === 'forces' && g.setupPending.includes(me.id)) return [{ type: 'ecazSetup', placements: quoteEcazStartingForces({ [ECAZ_START_LOCATIONS[0]]: ECAZ_START_FORCES }) }]; if ( placingForces && g.advanced && me.faction === 'beneGesserit' && !me.advisorSetup && me.reserves === 20 && (!g.setupStage || g.setupPending.includes(me.id)) && g.players.every((p) => p.faction !== 'fremen' || p.reserves === 10) ) return destinations(g, true) .filter((to) => to.t !== MOBILE_STRONGHOLD) .map((to) => ({ type: 'advisorSetup', territory: to.t, sector: to.s, })); if (makingPrediction && me.faction === 'beneGesserit' && !me.prediction) return [ { type: 'predict', faction: g.players.find((p) => p.id !== me.id)!.faction, turn: 3 + Math.floor(variation(g, 'prediction') * 5), }, ]; return []; } if ( !g.battle && !kullPurposeBlock(g, kullSpecialPurpose(me.faction)) && g.choamCashIn?.karamas.length && (me.spice ?? 0) < (level === 0 ? 2 : 5) ) { const card = g.choamCashIn.karamas[0]; const preserve = g.choamWorthless?.pending ?? (g.decision?.kind === 'choamMovement' ? choamPowerBotPlay(g, 'baliset', (card) => technologyCardValue(g, card))?.card.id : g.decision?.kind === 'choamStorm' ? choamPowerBotPlay(g, 'jubba', (card) => technologyCardValue(g, card))?.card.id : (gamontAction(g)?.card ?? g.choamWorthless?.cards.find((card) => card.name === 'Trip to Gamont')?.id)); const selected = g.choamCashIn.cards.filter( (c) => c.kind === 'worthless' && c.id !== card.id && c.id !== preserve, ); if ( selected.length && (!g.auction || g.auction.bidder !== me.id || g.auction.bid <= (me.spice ?? 0) + selected.length * 3 + (g.auction.allyPayment ?? 0)) ) return [ { type: 'card', mode: 'special', card: card.id, cards: selected.map((c) => c.id), }, ]; } if (g.phaseOpening) { if (g.phaseOpening.passed.includes(me.id)) return []; const amal = me.hand?.find((c) => c.effect === 'amal'); // Rival spice is private. Low own losses inform this choice. if ( amal && g.amalCards?.includes(amal.id) === true && g.turn > 1 && (me.spice ?? 0) <= (level === 0 ? 4 : 2) && (level > 0 || variation(g, 'amal') > 0.4) ) return [{ type: 'card', card: amal.id }]; return [{ type: 'ready' }]; } const sapho = saphoAction(g); if (sapho) return [sapho]; const specialCard = !kullPurposeBlock(g, kullSpecialPurpose(me.faction)) && !g.choamMarket && (!g.revival.pending || (me.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop')) && g.advanced && !me.specialKaramaUsed && me.hand?.find( (c) => canUseAsKaramaRole(g, me, c) && g.richeseGift?.pending?.card?.id !== c.id, ); if ( specialCard && me.faction === 'ixians' && g.phase === 5 && g.active === me.id && !g.response && !g.decision && g.mobileStronghold?.location && !g.homeworldMobility?.mobileStrongholdBlocked && (me.forces[MOBILE_LOCATION] ?? 0) > 0 ) { const capacity = (me.forces[MOBILE_LOCATION] ?? 0) * 2; const route = mobileRoutes(g, 2) .map((route) => ({ route, value: route.reduce( (sum, key) => sum + Math.min(g.spice[key] ?? 0, capacity), 0, ), })) .sort((a, b) => b.value - a.value)[0]; if (route && route.value >= [4, 5, 6, 6][level]) { const sandmaster = quoteSandmasterStronghold(g, me.id, route.route); return [ { type: 'card', mode: 'special', card: specialCard.id, route: route.route, ...(sandmaster ? { sandmasterPiles: sandmaster.collect } : {}), }, ]; } } if ( specialCard && me.faction === 'fremen' && g.phase === 1 && !g.summonedWorm ) { const targets = TERRITORIES.filter((t) => t.type === 'sand') .map((t) => ({ territory: t.id, enemy: g.players .filter((p) => p.id !== me.id && p.id !== me.ally) .reduce((sum, p) => sum + countAt(p, t.id), 0), own: countAt(me, t.id), })) .sort((a, b) => b.enemy * 3 + b.own - (a.enemy * 3 + a.own)); const choice = targets[0]; if ( choice && (choice.enemy >= (level === 0 ? 1 : 3) || (choice.own >= 5 && g.turn >= 3)) ) return [ { type: 'card', mode: 'special', card: specialCard.id, territory: choice.territory, }, ]; } if (specialCard && me.faction === 'harkonnen' && g.phase === 3) { const victim = g.players .filter((p) => p.id !== me.id && (p.handCount ?? 0) > 0) .sort( (a, b) => (b.ally === me.id ? -10 : 0) + (b.handCount ?? 0) - ((a.ally === me.id ? -10 : 0) + (a.handCount ?? 0)), )[0]; if (victim) return [ { type: 'card', mode: 'special', card: specialCard.id, target: victim.id, amount: Math.min(level === 0 ? 1 : 4, victim.handCount!), }, ]; } if (specialCard && me.faction === 'emperor' && g.phase === 4) { const dead = me.leaders .filter((l) => l.dead && !l.capturedBy && !l.gholaBy) .sort((a, b) => b.strength - a.strength)[0]; if (dead && (level === 0 || dead.strength >= 4)) return [ { type: 'card', mode: 'special', card: specialCard.id, leader: dead.id, }, ]; const eliteStock = me.elites?.tanks ?? 0; const amount = Math.min(3, me.tanks - (g.revival.sardaukarFreeBlock ? eliteStock : 0)); const elite = g.revival.sardaukarFreeBlock ? 0 : Math.min(amount, eliteStock); if (amount === 3 || (level === 0 && amount > 0)) return [ { type: 'card', mode: 'special', card: specialCard.id, amount, elite }, ]; } if (g.nexusEmperorBetrayal) return [{ type: 'nexusEmperorBetrayal', event: g.nexusEmperorBetrayal.event }]; if (g.nexusBgBetrayal) return [{ type: 'nexusBgBetrayal', event: g.nexusBgBetrayal.event }]; if (g.response) { const benefitEnemy = g.response.owner !== me.id && g.response.owner !== me.ally; const combatant = !!g.battle && [g.battle.attacker, g.battle.defender].includes(me.id); const targeted = combatant && ((g.response.kind === 'voice' && g.battle!.voice?.target === me.id) || (g.response.kind === 'prescience' && g.battle!.prescience?.player !== me.id) || (g.response.kind === 'nexusPrescience' && g.battle!.nexusInspection?.target === me.id) || g.response.kind === 'harkonnenTraitor' || g.response.kind === 'ecazOccupy' || g.response.kind === 'eliteStrength' || g.response.kind === 'fremenSupport' || g.response.kind === 'nexusSardaukar' || g.response.kind === 'choamBattleAid'); const auditThreat = g.response.kind === 'choamAudit' && g.auditor?.opponent === me.id && (g.auditor.count ?? 0) > 0; const movementThreat = (g.response.kind === 'fremenMovement' && !!g.response.location && fighterCount(me, splitLocation(g.response.location).territory) > 0) || (g.response.kind === 'nexusAdvisorFlip' && !!g.nexusAdvisors?.pending?.territories.some((territory) => fighterCount(me, territory) > 0)); const ecazBetrayalThreat = g.response.kind === 'nexusEcazBetrayal' && !!g.response.location && fighterCount(me, g.response.location) > 0 && !!me.ally && g.players.some(player => player.id === me.ally && player.faction === 'ecaz'); const card = me.hand?.find((c) => g.responseControls?.cancelCards.includes(c.id), ); if ( card && benefitEnemy && ((level >= 2 && (targeted || movementThreat || ecazBetrayalThreat || auditThreat)) || (level === 3 && ([ 'harkonnenBonus', 'choamCharity', 'choamBattleIncome', 'ecazCollection', ].includes(g.response.kind) || (g.response.kind === 'choamInflation' && g.response.intent === 'double')))) ) return [{ type: 'card', card: card.id, mode: 'cancel' }]; if (g.nexusCards && !g.responseControls?.hasPassed && ((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')))) return [{ type: 'passResponse' }]; return !g.responseControls?.cancelCards.length || g.responseControls.hasPassed ? [] : [{ type: 'passResponse' }]; } if (g.biddingEnd && !g.decision) return biddingEndActions( g, level, (card) => technologyCardValue(g, card), (gamontAction(g)?.card ?? g.choamWorthless?.cards.find((card) => card.name === 'Trip to Gamont')?.id), ); if (g.decision) { if (g.decision.player !== me.id) return []; const d = g.decision; // The dedicated pending-purchase policy owns event-only choices. // A decision without its private view offer must not become an ordinary action. if (d.kind === 'nexusIxianReplacement') return []; if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion' || d.kind === 'bureaucratPayment') return []; if (d.kind === 'smugglerCollection') return [{ type: 'decision', event: d.event, allocations: defaultSmugglerBattleAllocation(d.piles, d.amount) }]; if (d.kind === 'sandmasterVictorySpice') return d.piles.length ? [{ type: 'decision', event: d.event, key: d.piles[0].key }] : []; if (d.kind === 'harassWithdraw') { const offer = g.battle?.harassAllocation; if (!offer || offer.event !== d.event) return []; return [{ type: 'decision', event: d.event, returns: offer.selection ?? defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support) }]; } if (d.kind === 'nexusChoamInspection') return [{ type: 'decision', event: d.event, inspect: !!g.nexusChoamInspection?.canInspect }]; if (d.kind === 'choamAudit') return [{ type: 'decision', event: d.event, audit: true }]; if (d.kind === 'choamAuditPayment') { const count = g.auditor?.count ?? 0; // Only the AI's own hand and public spice inform the value of secrecy. const valuable = me.hand?.filter((c) => c.kind !== 'worthless').length ?? 0; const pay = count > 0 && (me.spice ?? 0) >= count && level > 0 && valuable > 0 && (me.spice ?? 0) >= count + (level === 1 ? 6 : 3); return [ { type: 'decision', event: d.event, pay, ...(pay ? { count } : {}) }, ]; } if (d.kind === 'ecazBattleLead') return ecazOccupyPolicyActions(g); if (d.kind === 'strongholdCopy') { const score = (id: string) => id === 'arrakeen' ? (me.spice ?? 0) < 3 ? 12 : 7 : id === 'carthag' ? me.hand?.some( (c) => c.kind === 'shield' || c.kind === 'weirdingWay', ) ? 9 : 2 : id === 'habbanya_ridge_sietch' ? g.battle?.defender === me.id ? 8 : 1 : id === 'sietch_tabr' ? 6 : (me.hand?.filter((c) => c.kind === 'worthless').length ?? 0) * 4; const choices = [...d.choices].sort((a, b) => level === 0 ? variation(g, a) - variation(g, b) : score(b) - score(a), ); return choices.map((stronghold) => ({ type: 'decision', event: d.event, stronghold, })); } if (d.kind === 'diplomatRetreat') { const destination = d.destinations[0]; let choice = destination?.choices[0]; for (const candidate of destination?.choices ?? []) if (!choice || candidate.normal + candidate.elite > choice.normal + choice.elite) choice = candidate; return [{ type: 'decision', event: d.event, destination: destination && choice ? destination.location : null, normal: choice?.normal ?? 0, elite: choice?.elite ?? 0, }]; } if (d.kind === 'diplomatDefense') { const battle = g.battle; const opponent = battle ? battle.attacker === me.id ? battle.defender : battle.attacker : null; const enemyWeapon = battle?.cards.find( (card) => card.id === (opponent ? battle.plans[opponent]?.weapon : null), ); const copiedDefense = battle?.cards.find( (card) => card.id === d.source, ); const useful = !!enemyWeapon && !!copiedDefense && weaponKills(enemyWeapon) && !weaponKills(enemyWeapon, copiedDefense); return [ { type: 'decision', event: d.event, card: useful ? (d.cards[0] ?? null) : null, }, ]; } if (d.kind === 'richeseAllyOpportunity') { const offer = alliedNoFieldOffer(g); return offer ? [offer, { type: 'decision', decline: true }] : [{ type: 'decision', decline: true }]; } if (d.kind === 'richeseAllyShipment') { const offer = g.richeseNoField?.allyOffer; if (!offer || offer.recipient !== me.id) return []; if ( offer.blocked || offer.amount <= 0 || (offer.payer === me.id && (me.spice ?? 0) < offer.cost) ) return [{ type: 'decision', event: offer.event, decline: true }]; return [ { type: 'decision', event: offer.event, accept: true, elite: level === 0 ? offer.eliteMin : offer.eliteMax, }, // A supported-looking offer can fail its authoritative arrival preflight. // Keep a legal exit without deriving private balances or token data. { type: 'decision', event: offer.event, decline: true }, ]; } if (d.kind === 'ecazSpice') { if (!g.ecazSpice?.allocation) return []; const allocation = g.ecazSpice.allocation; const lot = allocation.lots[allocation.index]; const offer = allocation.offer; const ownShare = offer ? me.id === lot.ecaz ? offer.ecazShare : lot.amount - offer.ecazShare : 0; const fairShare = me.id === lot.ecaz ? Math.floor(lot.amount / 2) : Math.ceil(lot.amount / 2); return [ { type: 'decision', event: g.ecazSpice.event, allocation: offer && ownShare >= fairShare ? { kind: 'accept' } : { kind: 'equal' }, }, ]; } if (d.kind === 'ecazPlacement') { const ambassadors = g.ambassadors; if ( ambassadors && !ambassadors.blocked && ambassadors.nextCost <= (me.spice ?? 0) ) { const token = ambassadors.tokens.find((t) => t.zone === 'supply'); const target = ambassadors.destinations[0]; if (token && target) return [{ type: 'decision', token: token.id, territory: target.id }]; } return [{ type: 'decision', decline: true }]; } if (d.kind === 'moritaniExtortion') { return g.extortion.pending?.event === d.event && g.extortion.pending.player === me.id ? [{ type: 'decision', event: d.event, pay: false }] : []; } if (d.kind === 'moritaniAssassinate') { const pending = g.moritaniAssassinate?.pending; if (!pending || pending.event !== d.event) return []; const cards = [...pending.cards].sort((a,b) => b.potentialBounty-a.potentialBounty || a.card.localeCompare(b.card)); return [cards.length && !pending.blocked ? {type:'decision',event:d.event,card:cards[0].card} : {type:'decision',event:d.event,decline:true}]; } if (d.kind === 'moritaniSpecialKarama') return [g.moritaniSpecialKarama ? moritaniSpecialKaramaBotAction(g.moritaniSpecialKarama) ?? { type: 'decision', event: d.event, decline: true } : { type: 'decision', event: d.event, decline: true }]; if (d.kind === 'moritaniRetention') { const candidates = (me.hand ?? []) .filter((card) => d.cards.includes(card.id)) .sort((a, b) => technologyCardValue(g, b) - technologyCardValue(g, a)); return [d.source === 'nexus' ? { type: 'decision', event: d.event, keep: g.nexusMoritaniRetention?.canKeep ? candidates[0]?.id ?? null : null } : { type: 'decision', keep: candidates[0]?.id ?? null }]; } if (d.kind === 'moritaniTerror') { const entry = g.terrorEntry; if (!entry) return []; if (entry.stage === 'select') { const candidate = entry.candidates?.find((candidate) => candidate.canReveal || candidate.canOfferAlliance); return [candidate ? { type: 'decision', token: candidate.token } : { type: 'decision', decline: true }]; } if (entry.stage === 'allianceReply') { const moritani = g.players.find( (player) => player.faction === 'moritani', )!; const previous = me.ally ? g.players.find((player) => player.id === me.ally) : undefined; const strength = (player: typeof me) => Object.values(player.forces).reduce((sum, count) => sum + count, 0); return [ { type: 'decision', accept: level > 0 && (!previous || strength(moritani) >= strength(previous)), }, ]; } if ( entry.stage === 'offer' && entry.canOfferAlliance && !( entry.kind === 'sneakAttack' && (entry.sneakAttack?.blocked || entry.sneakAttack?.maximum === 0) ) && level > 0 && !me.ally && g.players.find((player) => player.id === entry.entrant)?.ally === null ) return [{ type: 'decision', alliance: true }]; if (entry.stage === 'offer') return [ { type: 'decision', ...(entry.canReveal && !( entry.kind === 'sneakAttack' && (entry.sneakAttack?.blocked || entry.sneakAttack?.maximum === 0) ) ? { reveal: true } : { decline: true }), }, ]; if (entry.stage === 'sneakAttack') return [ { type: 'decision', amount: entry.sneakAttack?.blocked ? 0 : (entry.sneakAttack?.maximum ?? 0), }, ]; if (entry.stage === 'robbery') return [ { type: 'decision', choice: (me.hand?.length ?? 0) < me.handLimit ? 'card' : 'spice', }, ]; if (entry.stage === 'gift') return [{ type: 'decision', decline: true }]; const cards = [...(me.hand ?? [])]; const card = cards.find((c) => c.kind === 'worthless') ?? cards[0]; return card ? [{ type: 'decision', card: card.id }] : []; } if (d.kind === 'richeseBlackMarket') return [ { type: 'decision', event: g.richeseBidding!.event, decline: true }, ]; if (d.kind === 'richeseDeclaration') return [ { type: 'decision', event: g.richeseBidding!.event, position: level >= 2 ? 'last' : 'first', }, ]; if (d.kind === 'richeseUnbid') return [ { type: 'decision', event: g.richeseBidding!.event, keep: (me.hand?.length ?? 0) < me.handLimit, }, ]; if (d.kind === 'ecazAmbassador') { const entry = g.ambassadorEntry; if (!entry) return []; const action = { type: 'decision' as const, event: entry.event }; if (entry.noField && (entry.noField.stage === 'supply' || entry.noField.stage === 'consent' || (entry.noField.stage === 'own' && !entry.shipment?.maximum))) { const choice = entry.noField.options.find(option => !option.blocked); if (choice) return [choice.action]; if (entry.noField.decline) return [entry.noField.decline]; } const canBuyRichese = (me.spice ?? 0) >= 3 && (me.hand?.length ?? me.handLimit) < me.handLimit; const hasFremenGroup = Object.entries(me.forces).some( ([key, count]) => count > 0 && (splitLocation(key).sector === 0 || splitLocation(key).sector !== g.storm), ) || !!( me.noField?.deployed && isBoardNoFieldLocation(me.noField.deployed.location) && (me.noField.deployed.location.sector === 0 || me.noField.deployed.location.sector !== g.storm) ); if (entry.stage === 'allianceReply') return [{ ...action, accept: true }]; if (entry.stage === 'loan') return [{ ...action, loan: !!entry.dukeLoan && !entry.dukeLoan.blocked }]; if (entry.stage === 'revival') { const revival = entry.revival; if (!revival || revival.blocked) return [{ ...action, decline: true }]; if (revival.maximum < 1) return [revival.leaders[0] ? { ...action, leader: revival.leaders[0].id } : { ...action, decline: true }]; // Minimal legal participation: return as many Tanks counters as the // printed maximum allows, spending the single elite option first. const elite = revival.eliteMaximum; const forces = Math.min(revival.maximum, revival.tanks); return [{ ...action, forces, elite: Math.min(elite, forces) }]; } if (entry.stage === 'offer') { if (entry.effect === 'ecaz') { const recipient = entry.beneficiaries.find(option => !option.blocked)?.player ?? (entry.dukeAcquisition && !entry.dukeAcquisition.blocked ? entry.owner : null); return [ entry.allianceOffer && !entry.allianceOffer.blocked ? { ...action, trigger: true, beneficiary: entry.owner, choice: 'alliance', } : recipient ? { ...action, trigger: true, beneficiary: recipient, choice: 'duke', } : { ...action, decline: true }, ]; } const available = entry.beneficiaries.filter( (option) => !option.blocked && (entry.effect !== 'richese' || option.player !== me.id || canBuyRichese) && (entry.effect !== 'fremen' || option.player !== me.id || hasFremenGroup) && (entry.effect !== 'guild' || option.player !== me.id || me.reserves > 0), ); const beneficiary = available.find((option) => option.player === me.id) ?? available[0]; return [ beneficiary ? { ...action, trigger: true, beneficiary: beneficiary.player } : { ...action, decline: true }, ]; } if (entry.stage === 'copy') { const available = entry.copies.filter((option) => !option.blocked); const useful = available.filter( (option) => (option.effect !== 'richese' || canBuyRichese) && (option.effect !== 'fremen' || hasFremenGroup) && (option.effect !== 'guild' || me.reserves > 0), ); const choices = useful.length ? useful : available; const order = [ 'emperor', ...(me.hand?.some((card) => card.kind === 'worthless') ? ['choam'] : []), 'atreides', 'harkonnen', 'ixians', 'fremen', 'guild', 'richese', 'choam', ]; choices.sort((a, b) => level === 0 ? variation(g, a.effect) - variation(g, b.effect) : order.indexOf(a.effect) - order.indexOf(b.effect), ); return choices.length ? [{ ...action, effect: choices[0].effect }] : []; } if (entry.stage === 'move') return fremenAmbassadorMoves(g); if (entry.stage === 'ship') return guildAmbassadorShipments(g); if (entry.stage !== 'cards') return []; const cards = (me.hand ?? []).filter((card) => entry.cards.some( (option) => option.card === card.id && !option.blocked, ), ); if (entry.effect === 'choam') return [ { ...action, cards: cards .filter( (card) => card.kind === 'worthless' || (level > 0 && card.kind === 'hero' && (me.spice ?? 0) < 5), ) .map((card) => card.id), }, ]; cards.sort( (a, b) => technologyCardValue(g, a) - technologyCardValue(g, b), ); return cards.length ? [{ ...action, cards: [cards[0].id] }] : []; } if (d.kind === 'moritaniSetup') { const choices = TERRITORIES.filter((t) => g.players.every((p) => countAt(p, t.id) === 0), ); const target = (level > 0 ? choices.find((t) => t.type === 'stronghold') : undefined) ?? choices[0]; return target ? [ { type: 'decision', territory: target.id, sector: target.sectors[0], }, ] : []; } if (d.kind === 'moritaniPlacement') { const cunning = nexusMoritaniBotActions(g); if (cunning.length) return cunning; const tokens = g.moritaniTerror?.tokens ?? []; const destinations = TERRITORIES.filter( (t) => t.type === 'stronghold' && !tokens.some( (token) => token.status === 'placed' && token.location === t.id, ), ); const token = tokens.find( (token) => token.status === 'available' && 'kind' in token, ) ?? tokens.find((token) => token.status === 'placed' && 'kind' in token); const target = (level > 0 ? destinations.find((t) => g.players.some( (p) => p.id !== me.id && p.id !== me.ally && countAt(p, t.id) > 0, ), ) : undefined) ?? destinations[0]; return token && target ? [{ type: 'decision', token: token.id, territory: target.id }] : [{ type: 'decision', decline: true }]; } if (d.kind === 'choamStorm') { const play = choamPowerBotPlay(g, 'jubba', (card) => technologyCardValue(g, card)); const target = [...d.territories].sort( (a, b) => b.amount - a.amount || a.territory.localeCompare(b.territory), )[0]; const action = play && target ? choamPowerAction(g, play, { territory: target.territory }) : null; return [action ?? { type: 'decision', decline: true }]; } if (d.kind === 'choamMovement') { const play = choamPowerBotPlay(g, 'baliset', (card) => technologyCardValue(g, card)); const action = play && d.mover !== me.ally ? choamPowerAction(g, play) : null; return [action ?? { type: 'decision', decline: true }]; } if (d.kind === 'choamMentat') return [gamontAction(g) ?? { type: 'decision', done: true }]; if (d.kind === 'choamFreeRevival') { const play = choamPowerBotPlay(g, 'laLaLa', (card) => technologyCardValue(g, card)); const action = play && d.recipient !== me.ally && level > 0 ? choamPowerAction(g, play) : null; return [action ?? { type: 'decision', decline: true }]; } if (d.kind === 'choamBattleFunding') { const ally = g.players.find((p) => p.id === me.ally)!; const pool = g.combatLocations?.find( (place) => place.id === g.battle!.territory, )?.forces?.[ally.id]; const forces = pool ? pool.normal + pool.elite : countAt(ally, g.battle!.territory); const total = (me.spice ?? 0) + g.aid.pledged; const amount = Math.max( g.aid.pledged, Math.min(forces, Math.max(0, total - (level === 0 ? 4 : 2))), ); return [{ type: 'decision', amount }]; } if (d.kind === 'choamMarket') return [choamMarketPolicy( g, (card) => technologyCardValue(g, card), (gamontAction(g)?.card ?? g.choamWorthless?.cards.find((card) => card.name === 'Trip to Gamont')?.id), )]; if (d.kind === 'choamTradeReply') { const returned = [...(me.hand ?? [])].sort( (a, b) => technologyCardValue(g, a) - technologyCardValue(g, b), )[0]; return returned && g.choamMarket?.offered ? [{ type: 'decision', card: returned.id }] : [{ type: 'decision', decline: true }]; } if (d.kind === 'choamTradeConfirm') { const market = g.choamMarket!; return market.offered && market.returned && (level === 0 || technologyCardValue(g, market.returned) >= technologyCardValue(g, market.offered)) ? [{ type: 'decision', accept: true }] : [{ type: 'decision', decline: true }]; } if (d.kind === 'ixSetup') { const cards = g.ixTechnology?.setup; const sorted = [...(cards ?? [])].sort( (a, b) => technologyCardValue(g, b) - technologyCardValue(g, a), ); if (!sorted.length) return []; return [ { type: 'decision', card: sorted[0].id, }, ]; } if (d.kind === 'ixTechnology') { const offered = [...(me.hand ?? [])].sort( (a, b) => technologyCardValue(g, a) - technologyCardValue(g, b), )[0]; const known = g.ixTechnology?.known ?? []; const expected = known.length ? known.reduce((sum, c) => sum + technologyCardValue(g, c), 0) / known.length : 5; return offered && technologyCardValue(g, offered) < expected - (level === 0 ? 0 : 1) ? [{ type: 'decision', card: offered.id }] : [{ type: 'decision', decline: true }]; } if (d.kind === 'ixAllyCard') return [ { type: 'decision', accept: !!g.ixPurchased && technologyCardValue(g, g.ixPurchased) < (level === 0 ? 0.5 : 4), }, ]; if (d.kind === 'mobileStronghold') { if (!d.placement && g.homeworldMobility?.mobileStrongholdBlocked) return [{type: 'decision', decline: true}]; if (d.placement) return TERRITORIES.filter((t) => t.type !== 'stronghold') .flatMap((t) => t.sectors.map((s) => ({ type: 'decision', location: location(t.id, s), })), ) .sort( (a, b) => (splitLocation(a.location).sector === g.storm ? 1 : 0) - (splitLocation(b.location).sector === g.storm ? 1 : 0) || variation(g, b.location) - variation(g, a.location), ); const capacity = (me.forces[MOBILE_LOCATION] ?? 0) * 2; const routes = mobileRoutes(g, 3) .map((route) => ({ route, score: route.reduce( (sum, key) => sum + Math.min(g.spice[key] ?? 0, capacity), 0, ), })) .sort((a, b) => b.score - a.score); return [ ...routes .filter((r) => r.score > 0) .map(r => { const sandmaster = quoteSandmasterStronghold(g, me.id, r.route); return { type: 'decision', route: r.route, ...(sandmaster ? { sandmasterPiles: sandmaster.collect } : {}) }; }), { type: 'decision', decline: true }, ]; } if (d.kind === 'ixSubstitution') { const world = g.combatLocations?.find( (place) => place.id === d.territory && place.kind === 'homeworld', ); const sourceEntries = world ? [[world.id, world.forces?.[me.id]?.normal ?? 0] as const] : Object.entries(me.forces) .filter(([key]) => splitLocation(key).territory === d.territory) .map( ([key, n]) => [key, n - (me.elites?.forces[key] ?? 0)] as const, ); const max = Math.min( Object.values(d.losses).reduce((a, b) => a + b, 0), sourceEntries.reduce((sum, [, n]) => sum + n, 0), ); const count = level === 0 ? Math.min(1, max) : max; if (!count) return [{ type: 'decision', decline: true }]; const take = ( entries: [string, number][] | (readonly [string, number])[], ) => { let remaining = count; const result: Record = {}; for (const [key, n] of entries) { const selected = Math.min(n, remaining); if (selected) result[key] = selected; remaining -= selected; } return result; }; return [ { type: 'decision', sources: take(sourceEntries), recover: take(Object.entries(d.losses)), }, ]; } if (d.kind === 'revivalStop') { const target = g.players.find((p) => p.id === d.recipient)!; const noLeader = !target.leaders.some( (l) => !l.dead && controlsLeader(target, l), ); const shouldStop = target.id !== me.ally && (target.tanks >= [12, 8, 5, 3][level] || (level > 0 && d.revival === 'leader' && noLeader)); const offer = g.tleilaxuSpecialKarama; const card = offer?.cards[0]; const allowed = offer?.targets.some(choice => choice.player === target.id && !choice.blocked); if (card && offer && !offer.blocked && allowed && shouldStop) return [ { type: 'card', mode: 'special', card, target: target.id, }, ]; return [{ type: 'decision', decline: true }]; } if (d.kind === 'faceDance') { if ( d.blocked || !me.faceDancers?.some((c) => !c.revealed && c.leader === d.identity) || me.ally === d.winner || d.winners?.includes(me.id) || (!!me.ally && d.winners?.includes(me.ally)) ) return [{ type: 'decision', reveal: false }]; const winner = g.players.find((p) => p.id === d.winner)!; const world = g.combatLocations?.find( (place) => place.id === d.territory && place.kind === 'homeworld', ); const pool = world?.forces?.[winner.id]; let needed = world ? (pool?.normal ?? 0) + (pool?.elite ?? 0) : d.winners ? g.players.reduce((sum, player) => sum + (d.winners!.includes(player.id) ? fighterCount(player, d.territory) : 0), 0) : countAt(winner, d.territory); const sources: Record = world ? {} : { reserves: Math.min(me.reserves, needed), }; needed -= sources.reserves ?? 0; for (const [key, amount] of Object.entries(me.forces).sort( ([a], [b]) => (territory(splitLocation(a).territory).type === 'stronghold' ? 1 : 0) - (territory(splitLocation(b).territory).type === 'stronghold' ? 1 : 0), )) { const take = Math.min( needed, Math.max( 0, amount - (territory(splitLocation(key).territory).type === 'stronghold' ? 1 : 0), ), ); if (take) sources[key] = take; needed -= take; } return [ { type: 'decision', reveal: true, sources, ...(world ? {} : { sector: territory(d.territory).sectors.find((s) => s !== g.storm) ?? territory(d.territory).sectors[0], }), }, ]; } if (d.kind === 'homeworldDefense') { const b = g.battle!; const own = b.plans[me.id]; const opponentId = b.attacker === me.id ? b.defender : b.attacker; const opponent = b.plans[opponentId]; const snooper = me.hand?.find( (card) => richeseCardDefinition(card)?.card.effect === 'portableSnooper', ); const card = (id: string | null) => b.cards.find((held) => held.id === id); const dead = (late: boolean) => strongholdBattleEffects( card(own.weapon), late ? snooper : card(b.lateDefense[me.id] ?? own.defense), card(opponent.weapon), card(b.lateDefense[opponentId] ?? opponent.defense), b.poisonTooth[me.id] ?? true, b.poisonTooth[opponentId] ?? true, b.strongholdEffects[me.id], b.strongholdEffects[opponentId], ).attackerDead; const hasLeader = g.allLeaders.some((leader) => leader.id === own.leader) || (!!own.kwisatz && card(own.leader)?.kind === 'hero'); return [ { type: 'decision', event: d.event, use: !!snooper && hasLeader && dead(false) && !dead(true), }, ]; } if (d.kind === 'homeworldExplosion') { const choices = d.options .map((loss, choice) => ({ choice, cost: level === 0 ? variation(g, `homeworld-explosion-${choice}`) : loss.normal + loss.elite * 2.5, })) .sort((a, b) => a.cost - b.cost); return choices.length ? [{ type: 'decision', event: d.event, choice: choices[0].choice }] : []; } if (d.kind === 'techToken') { const token = [...d.choices].sort((a, b) => level === 0 ? variation(g, a) - variation(g, b) : ['heighliners', 'production', 'axlotl'].indexOf(a) - ['heighliners', 'production', 'axlotl'].indexOf(b), )[0]; return [{ type: 'decision', token }]; } if (d.kind === 'stoneAllocation') { const offer = g.battle?.stoneAllocation; const undialed = offer?.totals.at(-1); return offer && offer.owner === me.id && offer.event === d.event && undialed !== undefined ? [{type:'decision',event:d.event,undialed}] : []; } if (d.kind === 'stoneBurner') { const b = g.battle!, own = b.plans[me.id]; const enemy = me.id === b.attacker ? b.defender : b.attacker; const other = b.plans[enemy]; const card = (id: string | null) => b.cards.find((c) => c.id === id); const ownDisc = g.allLeaders.find((l) => l.id === own.leader); const otherDisc = g.allLeaders.find((l) => l.id === other.leader); const alreadyWinsByTraitor = b.traitorVoters.includes(me.id) && !other.kwisatz && !!matchingTraitor(me.traitors ?? [], other.leader, card(other.leader)); const side = me.id === b.attacker ? 'attacker' : 'defender'; const pools = b.stoneBurnerContext?.opponentPools ?? []; let context = b.stoneBurnerContext?.occupy; for (const player of [b.attacker, b.defender]) { const plan = b.plans[player]; if (![plan.weapon, plan.defense].includes('ecaz-harass-withdraw')) continue; const occupy = b.ecazOccupy?.lead === player ? { cardUser: player, ecaz: b.ecazOccupy.ecaz, canceled: b.ecazOccupy.canceled, } : undefined; context = stoneBurnerWithdrawalContext(context, player === b.attacker ? 'attacker' : 'defender', occupy); } const winners = b.ownForces ? pools.map((pool) => side === 'attacker' ? stoneBurnerComparison( b.ownForces!, own.dial, own.support, pool, other.dial, other.support, b.tieWinner === b.attacker ? 'attacker' : 'defender', context, ).winner : stoneBurnerComparison( pool, other.dial, other.support, b.ownForces!, own.dial, own.support, b.tieWinner === b.attacker ? 'attacker' : 'defender', context, ).winner, ) : []; const winning = winners.length > 0 && winners.every((winner) => winner === side); const effects = strongholdBattleEffects( card(own.weapon), card(b.lateDefense[me.id] ?? own.defense), card(other.weapon), card(b.lateDefense[enemy] ?? other.defense), b.poisonTooth[me.id] ?? true, b.poisonTooth[enemy] ?? true, b.strongholdEffects[me.id], b.strongholdEffects[enemy], ); const ownValue = battleLeaderStrength(ownDisc, otherDisc); const otherValue = battleLeaderStrength(otherDisc, ownDisc); const ownAdditional = effects.attackerDead ? 0 : ownValue; const otherAdditional = effects.defenderDead ? 0 : otherValue; const bounty = effects.noBounty ? 0 : ownAdditional + otherAdditional; const explosion = battleWeaponsExplode( card(own.weapon), card(b.lateDefense[me.id] ?? own.defense), card(other.weapon), card(b.lateDefense[enemy] ?? other.defense), ); const benefit = otherAdditional - ownAdditional * [1.25, 1.5, 2, 2.5][level] + (winning ? bounty : -bounty) * 0.5; return [ { type: 'decision', event: d.event, mode: !alreadyWinsByTraitor && !explosion && benefit > 0 ? 'kill' : 'ignore', }, ]; } if (d.kind === 'poisonTooth') { const b = g.battle!; const a = b.plans[b.attacker], opponent = b.plans[b.defender]; const c = (id: string | null) => b.cards.find((card) => card.id === id); const score = (activate: boolean) => { const effects = strongholdBattleEffects( c(a.weapon), c(b.lateDefense[b.attacker] ?? a.defense), c(opponent.weapon), c(b.lateDefense[b.defender] ?? opponent.defense), me.id === b.attacker ? activate : (b.poisonTooth[b.attacker] ?? true), me.id === b.defender ? activate : (b.poisonTooth[b.defender] ?? true), b.strongholdEffects[b.attacker], b.strongholdEffects[b.defender], ); const strength = (id: string, dead: boolean) => dead || effects.stunned ? 0 : battleLeaderStrength( g.allLeaders.find((l) => l.id === b.plans[id].leader), g.allLeaders.find( (l) => l.id === b.plans[id === b.attacker ? b.defender : b.attacker].leader, ), ) + (b.plans[id].kwisatz ? 2 : 0); const av = a.dial + strength(b.attacker, effects.attackerDead) + (b.native === b.attacker ? b.nativeBattleStrength : 0), dv = opponent.dial + strength(b.defender, effects.defenderDead) + (b.native === b.defender ? b.nativeBattleStrength : 0); const winsA = av > dv || (av === dv && b.tieWinner === b.attacker); const wins = winsA === (me.id === b.attacker); const ownDead = me.id === b.attacker ? effects.attackerDead : effects.defenderDead; const enemyDead = me.id === b.attacker ? effects.defenderDead : effects.attackerDead; return ( (wins ? 100 : 0) + (ownDead ? 0 : 15) + (enemyDead ? 3 : 0) - (activate ? 2 : 0) ); }; return [ { type: 'decision', ...(d.event ? { event: d.event } : {}), activate: level === 0 ? variation(g, 'tooth') > 0.5 : score(true) > score(false), }, ]; } if (d.kind === 'fullPlanOffer') { return [atreidesFullPlanAction(g) ?? { type: 'decision', decline: true }]; } // A legacy inspection gate is cleared by authoritative normalization. if (d.kind === 'fullPlanRead') return []; if (d.kind === 'homeworldShipmentGuild') { const world = g.homeworlds?.worlds?.find((home) => home.id === d.destination); const enemy = d.shipper !== me.id && d.shipper !== me.ally; const threatened = world?.native === me.id || !!world?.forces[me.id]; if (specialCard && enemy && (level === 0 || threatened || (level >= 2 && d.amount >= 5))) return [{ type: 'card', mode: 'special', card: specialCard.id }]; return [{ type: 'decision', event: d.event, allow: true }]; } if (d.kind === 'guildShipment') { const enemy = d.shipper !== me.id && d.shipper !== me.ally; const threatened = fighterCount(me, d.territory) > 0; const stronghold = territory(d.territory).type === 'stronghold'; if ( specialCard && enemy && (level === 0 || threatened || (level >= 2 && stronghold && d.amount >= 3)) ) return [{ type: 'card', mode: 'special', card: specialCard.id }]; return [{ type: 'decision', allow: true }]; } if (d.kind === 'handExchange') { const value = (c: NonNullable[number]) => level === 0 ? variation(g, c.id) : c.kind === 'worthless' ? 0 : c.kind === 'hero' ? 2 : c.effect === 'karama' ? 8 : ['shield', 'snooper'].includes(c.kind) ? 7 : 6; const returnCards = [...(me.hand ?? [])] .sort((a, b) => value(a) - value(b)) .slice(0, d.count) .map((c) => c.id); return [{ type: 'decision', returnCards }]; } if (d.kind === 'captureOffer') return [{ type: 'decision', accept: true }]; if (d.kind === 'capturedLeader') { const captive = me.leaders.find((l) => l.id === d.leader); const ownLiving = me.leaders.some( (l) => l.faction === me.faction && !l.dead, ); return [ { type: 'decision', mode: ownLiving && level > 0 && (captive?.strength ?? 0) >= 3 ? 'keep' : 'execute', }, ]; } if (d.kind === 'guildTiming') { const urgent = g.players.some( (p) => p.id !== me.id && p.id !== me.ally && closeToStrongholdVictory( g.victoryProgress?.find((row) => row.player === p.id), ), ); return [ { type: 'decision', take: level === 0 || (level === 1 ? d.next === me.id : level === 3 && urgent && (me.spice ?? 0) >= 3), }, ]; } if (d.kind === 'intrusion') { const enemies = g.players .filter((p) => p.id !== me.id && p.id !== me.ally) .reduce((n, p) => n + fighterCount(p, d.territory), 0); return [ { type: 'decision', accept: level === 0 || enemies >= countAt(me, d.territory), }, ]; } if (d.kind === 'advisorBattle') { const targets = d.territories.filter( (t) => level > 0 && g.players .filter((p) => p.id !== me.id && p.id !== me.ally) .every((p) => fighterCount(p, t) < countAt(me, t)), ); return [ ...targets.map((territory) => ({ type: 'decision', accept: true, territory, })), { type: 'decision', accept: false }, ]; } if(d.kind==='advisor'&&!d.ambassadorEvent&&(liveShipmentPromises(g.shipmentPromises??[],me.id,g.turn).length || (liveMovementPromises(g.movementPromises??[],me.id,g.turn).some(promise=>promise.answer) || liveMovementExpressionPromises(g.movementExpressionPromises??[],me.id,g.turn).some(promise=>promise.answer)))) return [{type:'decision',accept:false}]; if (d.kind === 'advisor' && (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)) { const choices = (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)!.choices.filter( (choice) => !choice.blocked, ); return [ ...choices.map((choice) => ({ type: 'decision', accept: true, accompany: choice.accompany, territory: choice.territory, sector: choice.sector, amount: choice.amount, })), { type: 'decision', accept: false }, ]; } if (d.kind === 'advisor' && (liveShipmentPromises(g.shipmentPromises ?? [], me.id, g.turn).length || (liveMovementPromises(g.movementPromises ?? [], me.id, g.turn).some(promise => promise.answer) || liveMovementExpressionPromises(g.movementExpressionPromises ?? [], me.id, g.turn).some(promise => promise.answer)))) // A waiting promise may need these reserves. Declining is the minimal // legal policy until optional accompaniment strategy is implemented. return [{ type: 'decision', accept: false }]; if (d.kind === 'advisor') return [ ...(g.advanced && d.destination && (g.homeworldMobility?.advisorAccompanyMaximum ?? 1) > 0 ? [{ type: 'decision', accept: true, accompany: true }] : []), ...((g.homeworldMobility?.advisorSinkMaximum ?? 1) > 0 ? [{ type: 'decision', accept: true, amount: level === 0 ? 1 : (g.homeworldMobility?.advisorSinkMaximum ?? 1) }] : []), { type: 'decision', accept: false }, ]; if (d.kind === 'discoveryDiscard') return discoveryBotActions(g); if (d.kind === 'discoveryEntry') return discoveryEntryBotActions(g); if (d.kind === 'ecologicalStorm') return discoveryStormActions(g); if (d.kind === 'jacurutuDial') return g.jacurutuDial?.choices.map((_,allocation) => ({type:'decision',event:d.event,allocation})) ?? []; if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g); if (d.kind === 'nexusFremenRideBetrayal') { const reaction = g.nexusFremenRideBetrayal; return reaction && reaction.player === me.id ? [{ type: 'decision', event: reaction.event, use: reaction.canUse }] : []; } if (d.kind === 'nexusFremenProtection') { const offer = g.nexusFremenProtection; return offer && !offer.blocked ? [{ type: 'nexusFremenProtection', event: offer.event, use: offer.canUse }] : []; } if (d.kind === 'nexusEmperorPayment') return [{ type: 'nexusEmperorPayment', event: d.event, use: !!g.nexusEmperorPayment?.canUse && !g.nexusEmperorPayment.blocked }]; if (d.kind === 'nexusEmperorSellerPurchase') return [{ type: 'nexusEmperorSellerPurchase', event: d.event, use: !!g.nexusEmperorSellerPurchase?.canUse && !g.nexusEmperorSellerPurchase.blocked }]; if (d.kind === 'nexusEmperorAmbassadorPurchase') return [{ type: 'nexusEmperorAmbassadorPurchase', event: d.event, use: !!g.nexusEmperorAmbassadorPurchase?.canUse && !g.nexusEmperorAmbassadorPurchase.blocked }]; if (d.kind === 'nexusFremenCunningOffer') return [{ type: 'decision', event: d.event, accept: g.nexusCards?.card === 'fremen' && me.faction === 'fremen' && !me.ally && Object.entries(me.forces).some(([key, count]) => count > 0 && splitLocation(key).sector !== g.storm && TERRITORIES.some(t => t.id === splitLocation(key).territory && t.type === 'sand')) }]; if (d.kind === 'nexusFremenCunningRide') { const decline: Action = { type: 'decision', event: d.event, accept: false }; const candidates: Action[] = []; for (const [key, count] of Object.entries(me.forces)) { if (count <= 0 || splitLocation(key).sector === g.storm) continue; const source = splitLocation(key).territory; if (!TERRITORIES.some(t => t.id === source && t.type === 'sand')) continue; const elite = me.elites?.forces[key] ?? 0; const forces = { [key]: { normal: count - elite, elite } }; for (const to of destinations(g)) { if (fremenCunningPublicDestinationBlock(g, me.id, source, { territory: to.t, sector: to.s }) || !botEntryAllowed(g, me, to.t, to.s, 'move')) continue; const sandmaster = sandmasterCunningCollection(g, me.id, source, to.t, to.s); candidates.push({ type: 'decision', event: d.event, accept: true, source, forces, territory: to.t, sector: to.s, ...(sandmaster?.blocked === null && sandmaster.key ? { sandmasterCollect: true, sandmasterPile: sandmaster.key } : {}) }); if (candidates.length >= 16) return [...candidates, decline]; } } return [...candidates, decline]; } if (d.kind === 'wormProtection') return [{ type: 'decision', accept: true, ...(d.event ? {event:d.event, ally:d.ally} : {}) }]; if (d.kind === 'stormLosses') return [ { type: 'decision', elite: level === 0 ? d.maxElite : d.minElite }, ]; if (d.kind === 'wormPlacement') { const targets = TERRITORIES.filter((t) => t.type === 'sand') .map((t) => ({ t: t.id, score: g.players .filter((p) => p.id !== me.id && p.id !== me.ally) .reduce((sum, p) => sum + countAt(p, t.id) * 3, 0) + Object.entries(g.spice) .filter(([key]) => splitLocation(key).territory === t.id) .reduce((sum, [, n]) => sum + n, 0), })) .sort((a, b) => b.score - a.score); return [ { type: 'decision', accept: true, territory: targets[0].t }, { type: 'decision', accept: false }, ]; } if (d.kind === 'sukRescue') { return d.options.map((option, choice) => ({ choice, score: option.normal + option.elite * (level > 0 ? 2.5 : 1) + (option.kept ? 1 + (option.kept.kind === 'elite' && level > 0 ? 0.5 : 0) : 0), })).sort((a, b) => b.score - a.score) .map(({ choice }) => { const option = d.options[choice]; const normal = option.normal - (option.kept?.kind === 'normal' ? 1 : 0); const elite = option.elite - (option.kept?.kind === 'elite' ? 1 : 0); if (!d.reserveHomes || normal + elite === 0) return { type: 'decision', event: d.event, choice }; const primary = d.reserveHomes.find(home => !home.secondary)!; const destinations: Record = {}; for (const home of d.reserveHomes) destinations[home.id] = { normal: home.id === primary.id ? normal : 0, elite: home.id === primary.id ? elite : 0, }; return { type: 'decision', event: d.event, choice, destinations }; }); } if (d.kind === 'rihani') { if (d.stage === 'offer') return [{ type: 'decision', event: d.event, draw: true }]; const pending = g.rihani?.pending; if (!pending || pending.owner !== me.id || pending.event !== d.event || !pending.drawn?.length || !pending.eligible?.length) return []; const value = (id: string) => { const leader = g.allLeaders.find((l) => l.id === id); return (leader?.strength ?? 0) + (leader?.faction === me.faction ? -10 : 10); }; const drawn = level === 0 ? pending.drawn : [...pending.drawn].sort((a,b) => value(b) - value(a)); const old = level === 0 ? pending.eligible : [...pending.eligible].sort((a,b) => value(a) - value(b)); return [{ type: 'decision', event: d.event, cards: [drawn[0], old[0]] }]; } if (d.kind === 'battleLosses') { const choices = d.options .map((o, choice) => ({ choice, score: o.normal + o.elite * (level > 0 ? 2.5 : 1), })) .sort((a, b) => a.score - b.score); return choices.map(({ choice }) => ({ type: 'decision', choice })); } if (d.kind === 'auctionPayment') { const karama = me.hand?.find((c) => !kullPurposeBlock(g, 'auction-free-purchase') && canUseAsKaramaRole(g, me, c), ); const use = !!karama && ((g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available + (g.auction?.coalitionPayment ?? 0) || (level >= 1 && (g.auction?.bid ?? 0) >= 4)); return [ { type: 'decision', karama: use }, { type: 'decision', karama: !!karama }, ]; } if (d.kind === 'homeworldRevivalDeployment') return homeworldRevivalDeploymentActions(g); if (d.kind === 'caladanReinforcement') return caladanReinforcementActions(g); if (d.kind === 'grummanCollection') return grummanCollectionActions(g); if (d.kind === 'homeworldOccupiedIncome') { const offer = g.homeworldOccupiedIncome; return offer && !offer.blocked ? [{ type: 'decision', event: offer.event, world: offer.world, ownAmount: offer.amount }] : []; } if (d.kind === 'battleCards') return [ { type: 'decision', discard: d.cards.filter( (id) => me.hand?.find((c) => c.id === id)?.kind === 'worthless', ), }, ]; if (d.kind === 'wormRide') return [ ...destinations(g) .filter( (to) => to.t !== d.territory && botEntryAllowed(g, me, to.t, to.s, 'move') && !botWormRideArrivalBlock(g, to.t, to.s), ) .slice(0, 16) .map( (to): Action => { const sandmaster = sandmasterWormCollection(g, me.id, to.t, to.s); return { type: 'decision', accept: true, territory: to.t, sector: to.s, ...(sandmaster?.blocked === null ? { sandmasterCollect: true, sandmasterPile: sandmaster.key } : {}), forces: Object.fromEntries( Object.entries(me.forces).filter( ([key]) => splitLocation(key).territory === d.territory && splitLocation(key).sector !== g.storm, ), ), }; }, ), { type: 'decision', accept: false }, ]; } const box = g.nullentropy; if ( box && !box.blocked && !box.search && (me.spice ?? 0) >= [5, 6, 7, 8][level] ) return [{ type: 'card', card: box.card.id }]; const distrans = g.distrans; if (distrans && !distrans.blocked) { const ally = g.players.find((player) => player.id === me.ally); const choice = distrans.choices.find( (candidate) => candidate.recipient === ally?.id && !candidate.blocked, ); if (ally && choice) { let given = !['beneGesserit', 'choam'].includes(me.faction) && ['beneGesserit', 'choam'].includes(ally.faction) ? choice.cards.find((card) => card.kind === 'worthless') : undefined; if (!given && (me.hand?.length ?? 0) >= me.handLimit) { given = choice.cards.find((card) => { const ordinary = [ 'projectile', 'poison', 'shield', 'snooper', ].includes(card.kind); const karama = card.effect === 'karama' && canUseAsKarama(g.advanced, me.faction, card); return ( (ordinary || karama) && me.hand?.some( (other) => other.id !== card.id && other.id !== distrans.card.id && (ordinary ? other.kind === card.kind : other.effect === 'karama' && canUseAsKarama(g.advanced, me.faction, other)), ) ); }); } if (given) return [ { type: 'card', card: distrans.card.id, target: ally.id, give: given.id, }, ]; } } const gift = g.richeseGift; if ( gift?.owner === me.id && !gift.blocked && !gift.pending && g.phase === 3 ) { const ally = g.players.find( (player) => player.id === gift.recipient && player.id === me.ally, ); const ownCount = me.hand?.length ?? 0; // Other hands' contents stay private. Only Bidding projects their card counts; // make space in a full hand, rather than assume an ally needs our sole Karama. if ( ally?.handCount !== undefined && ownCount >= me.handLimit && ally.handCount < ownCount && ally.handCount < ally.handLimit ) { const card = gift.cards.find( (candidate) => richeseCardDefinition(candidate)?.card.effect === 'karama', ); if (card) return [{ type: 'richeseGift', card: card.id }]; } } const richesePurchase = g.richeseSpecialKarama; if ( richesePurchase && !richesePurchase.blocked && richesePurchase.karamas.length && (me.spice ?? 0) >= [6, 7, 8, 8][level] ) { // Minimal legal participation prefers reusable canonical Karama. // Keep a spice reserve and never inspect another player's hidden cache. const choice = richesePurchase.cards.find( (card) => richeseCardDefinition(card)?.card.effect === 'karama', ); if (choice) return [ { type: 'card', mode: 'special', card: richesePurchase.karamas[0].id, acquire: choice.id, ...(richesePurchase.emperorNexus?.canUse && !richesePurchase.emperorNexus.blocked ? { emperorNexus: true, nexusEvent: richesePurchase.emperorNexus.event } : {}), }, ]; } if (g.phase === 0) { if (g.stormPending !== null) return g.ready.includes(me.id) ? [] : [{ type: 'ready' }]; if (g.stormDialers.includes(me.id) && !g.stormSubmitted.includes(me.id)) return [ { type: 'stormDial', amount: g.turn === 1 ? Math.floor(variation(g, 'storm') * 21) : 1 + Math.floor(variation(g, 'storm') * 3), }, ]; return []; } if (g.phase === 1) { const group = g.coalitionNexus; if (group?.proposal) { const proposal = group.proposal; const participants = [...proposal.original.members, proposal.entrant]; return participants.includes(me.id) && !proposal.approvals.includes(me.id) ? [{type: 'coalitionAnswer', event: proposal.event, nexus: proposal.nexus, turn: proposal.turn, accept: true, expectedRevision: group.revision}] : []; } const thumper = me.hand?.find((c) => c.effect === 'thumper'); const prior = g.spiceDiscardTop[0]; if ( thumper && !g.ready.includes(me.id) && g.thumperCards?.includes(thumper.id) === true && g.beforeSpiceDraw && g.turn > 1 && prior && 'territory' in prior ) { const exposed = (p: GameView['players'][number]) => p.faction === 'fremen' ? 0 : countAt(p, prior.territory); const enemy = g.players .filter((p) => p.id !== me.id && p.id !== me.ally) .reduce((n, p) => n + exposed(p), 0); const friends = exposed(me) + (me.ally ? exposed(g.players.find((p) => p.id === me.ally)!) : 0); if ( !g.sandtrout && (level === 0 ? variation(g, 'thumper') > 0.5 : enemy > friends) ) return [{ type: 'card', card: thumper.id }]; } if ( g.spiceWindow && !g.ready.includes(me.id) && harvesterAvailable(g.spiceWindow) && countAt(me, g.spiceWindow.territory) ) { const card = me.hand?.find((c) => c.effect === 'harvester'); if (card) return [{ type: 'card', card: card.id }]; } if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' && !group.groups.some(alliance => alliance.members.includes(me.id))) { const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < 6); if (alliance) return [{type:'coalitionPropose', groupId:alliance.id, entrant:me.id, expectedRevision:group.revision}]; } if (g.nexus && !g.spiceWindow && !me.ally && !group?.groups.some(alliance => alliance.members.includes(me.id))) { const offer = g.players.find( (player) => player.id !== me.id && g.allianceOffers[player.id] === me.id && !g.homeworldAllianceBlocks?.[player.id], ); if (offer) return [{ type: 'alliance', target: offer.id }]; if (level > 0 && !g.ready.includes(me.id) && !g.allianceOffers[me.id]) { const target = g.players.find( (player) => player.id !== me.id && !player.ally && !g.homeworldAllianceBlocks?.[player.id] && !group?.groups.some(alliance => alliance.members.length >= 3 && alliance.members.includes(player.id)), ); if (target) return [{ type: 'alliance', target: target.id }]; } } return g.ready.includes(me.id) ? [] : [{ type: 'ready' }]; } // Reuse the native advisor-decision preference; eligibility comes only from // the shared current-window projection, including first actual shipment. if (level > 0 && g.advisorPreparation && !g.advisorPreparation.blocked) { const territory = g.advisorPreparation.territories.find(t => g.players .filter(p => p.id !== me.id && p.id !== me.ally) .every(p => fighterCount(p, t) < countAt(me, t))); if (territory) return [{ type: 'prepareAdvisors', territory }]; } if (g.phase === 2 && g.charity.orderBlocked) return []; if ( g.phase === 2 && !me.charityClaimed && !g.charity.incomePending && !g.charity.truthBlocked && g.charity.amount > 0 ) return [{ type: 'charity' }]; if (g.phase === 3) { const lot = g.richeseAuction; if (lot) { if ( lot.outcome || !lot.eligible.includes(me.id) || (lot.method === 'silent' ? lot.submitted.includes(me.id) : lot.active !== me.id) ) return []; const pass: Action = { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null, allyPayment: 0, }; if (lot.source === 'blackMarket' && lot.owner === me.id) return [pass]; const value = lot.card ? technologyCardValue(g, lot.card) : 5; const budget = Math.min( lot.ownAvailable + lot.allyAvailable, [1, 3, 5, 7][level] + ((me.hand?.length ?? 0) === 0 ? 2 : 0), level === 0 ? 3 : Math.max(0, Math.floor(value)), ); const amount = lot.method === 'silent' ? budget : lot.bid + 1; if (amount <= 0 || amount > budget) return [pass]; return [ { type: 'richeseBid', event: lot.event, amount, allyPayment: Math.max(0, amount - lot.ownAvailable), }, ...(lot.method === 'silent' ? [] : [pass]), ]; } if (g.auction?.active !== me.id) return []; if (normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit)) return [{ type: 'passBid' }]; const budget = Math.min( (me.spice ?? 0) + g.aid.available, [1, 3, 5, 7][level] + ((me.hand?.length ?? 0) === 0 ? 2 : 0), ); return g.auction.bid < budget ? [{ type: 'bid', amount: g.auction.bid + 1 }, { type: 'passBid' }] : [{ type: 'passBid' }]; } if (g.phase === 4) { // Reuse the existing public-Tanks preference, with shared current permission. const prevention = g.tleilaxuSpecialKarama; if (prevention && !prevention.blocked && !prevention.pending && prevention.cards.length) { const target = g.players.find(player => player.id !== me.id && player.id !== me.ally && player.tanks >= [12, 8, 5, 3][level] && prevention.targets.some(choice => choice.player === player.id && !choice.blocked)); if (target) return [{ type: 'card', mode: 'special', card: prevention.cards[0], target: target.id }]; } const actions: Action[] = []; const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu'); const ordinaryLeaderAvailable = g.revival.leaders.some((l) => !l.early); if (me.faction === 'tleilaxu') { if ( g.advanced && me.leaders.filter((l) => !l.dead && controlsLeader(me, l)).length < 5 ) { const candidates = g.players .filter((p) => p.id !== me.id) .flatMap((p) => p.leaders) .filter( (l) => l.faction !== me.faction && l.dead && !l.capturedBy && l.id !== 'duke-vidal' && l.id !== 'choam-auditor' && !me.gholaBlocked?.includes(l.id), ) .sort((a, b) => b.strength - a.strength); for (const leader of candidates) if ( (g.revival.discount ? Math.ceil(leader.strength / 2) : leader.strength) <= (me.spice ?? 0) - [5, 3, 2, 1][level] ) actions.push({ type: 'reviveForeignGhola', leader: leader.id }); } for (const [player, request] of Object.entries(g.revivalRequests)) { if (request.price === null && !request.declined) { const value = g.allLeaders.find((l) => l.id === request.leader)?.strength ?? 2; actions.push({ type: 'quoteLeaderRevival', target: player, amount: me.ally === player ? 0 : value, }); } } if ( me.ally && !g.revivalRules.discountBlocked && g.revivalRules.allyDiscount !== me.ally ) actions.push({ type: 'tleilaxuAllyDiscount' }); if (!g.revivalRules.limitBlocked) for (const player of g.players) if ( player.id !== me.id && player.tanks > 3 && !g.revivalRules.expanded.includes(player.id) ) actions.push({ type: 'tleilaxuRevivalLimit', target: player.id }); } else if (tleilaxu && !me.leaderRevived) { const request = g.revivalRequests[me.id]; // The server re-validates that the requested leader is still a dead, // uncaptured native disc (or a dead Kwisatz Haderach) at acceptance, so // never keep proposing an acceptance the authoritative guard rejects. const requestedLeader = request?.leader === undefined ? undefined : me.leaders.find((l) => l.id === request.leader); const stillRevivable = !!request && (request.leader === 'kwisatz' ? me.kwisatz?.dead === true : !!requestedLeader?.dead && !requestedLeader.capturedBy); if ( request && !request.declined && request.price !== null && request.price <= (me.spice ?? 0) && stillRevivable ) actions.push({ type: 'acceptLeaderRevival' }); else if (request && !stillRevivable) // Clear an unhonourable request so the phase can request again. actions.push({ type: 'cancelLeaderRequest' }); if ( !request && me.leaders.filter((l) => l.faction === me.faction && nativeAvailable(l)) .length <= 2 && (me.leaders.some( (l) => l.faction === me.faction && nativeAvailable(l), ) || me.leaders.some((l) => l.dead && l.gholaBy)) ) { const dead = me.leaders .filter( (l) => l.dead && l.id !== 'duke-vidal' && l.faction === me.faction && !l.capturedBy && (l.gholaBy || (!ordinaryLeaderAvailable && me.leaders.some( (leader) => leader.faction === me.faction && nativeAvailable(leader), ))) && !g.revivalRules.earlyBlocked.includes(`${me.id}:${l.id}`), ) .sort((a, b) => b.strength - a.strength)[0]; if (dead && (me.spice ?? 0) >= dead.strength) actions.push({ type: 'requestLeaderRevival', leader: dead.id }); } } if (g.fremenRevivalGrant && !g.fremenRevivalGrant.granted && !g.fremenRevivalGrant.blocked) actions.push({ type: 'grantRevival' }); for (const grant of g.fremenGroupRevivalGrant ?? []) if (!grant.granted && !grant.blocked) actions.push({type:'grantRevival', target:grant.recipient}); const emperorGroup = g.coalitionEmperor; const emperorAllies = me.ally ? [me.ally] : emperorGroup?.allies ?? []; if (me.faction === 'emperor' && level > 0) for (const allyId of emperorAllies) { const ally = g.players.find((p) => p.id === allyId)!; const available = emperorGroup?.availableSpice ?? me.spice ?? 0; const extra = g.revivalPrevention?.player === ally.id ? 0 : Math.min( ally.tanks - (ally.elites?.tanks ?? 0) + Math.min( ally.elites?.tanks ?? 0, eliteRevivalRemaining(ally, g.advanced), ), 3 - (g.emperorExtra[ally.id] ?? 0), Math.floor(Math.max(0, available - 3) / 2), ); for (let amount = extra; amount > 0; amount--) { const minimumElite = Math.max( 0, amount - (ally.tanks - (ally.elites?.tanks ?? 0)), ); for (const elite of new Set([ Math.min(amount, eliteRevivalRemaining(ally, g.advanced)), minimumElite, ])) if (paidForceRevivalCost(ally, amount, elite) <= available - 3) actions.push({ type: 'emperorRevival', amount, elite, ...(emperorGroup ? {target: ally.id} : {}) }); } } const freeRemaining = g.revival.freeRemaining; const eliteAllowance = Math.min( me.elites?.tanks ?? 0, eliteRevivalRemaining(me, g.advanced), ); const maximum = Math.min( me.tanks - (me.elites?.tanks ?? 0) + eliteAllowance, g.revival.forcesRemaining, ); for (let amount = maximum; amount > 0; amount--) { const minimumElite = Math.max( 0, amount - (me.tanks - (me.elites?.tanks ?? 0)), ); const preferredElite = level > 0 ? Math.min(amount, eliteAllowance) : minimumElite; for (const elite of new Set([preferredElite, minimumElite])) { const quote = forceRevivalPrice( me.faction, amount, elite, Math.min(amount, freeRemaining), g.revival.discount, undefined, g.revival.choamBlocked, !!g.revival.sardaukarFreeBlock, ); if ( quote.cost === 0 || quote.cost <= (me.spice ?? 0) - [5, 3, 2, 1][level] ) actions.push({ type: 'revive', amount, elite }); } } if (g.revival.kwisatz?.affordable) actions.push({ type: 'reviveKwisatz' }); for (const leader of g.revival.leaders.filter((l) => l.affordable)) actions.push({ type: 'reviveLeader', leader: leader.id }); if ( !g.ready.includes(me.id) && (g.revival.prevented || !( g.revivalRequests[me.id]?.price === null && !g.revivalRequests[me.id]?.declined )) ) actions.push({ type: 'ready' }); return actions.filter(action => { if (['revive','reviveLeader','reviveKwisatz','reviveForeignGhola','requestLeaderRevival', 'acceptLeaderRevival','cancelLeaderRequest','ready'].includes(action.type)) return !g.revival.orderBlocked && (action.type === 'ready' || action.type === 'cancelLeaderRequest' || !g.revival.prevented); if (action.type === 'emperorRevival' && g.revivalOrder) return g.revivalOrder.remaining[0] === (action.target ?? me.ally); return true; }); } if (g.phase === 5) { const kulon = choamPowerBotPlay(g, 'kulon', (card) => technologyCardValue(g, card)); if ( kulon && g.active === me.id && Object.keys(me.forces).length && (me.moved ?? 0) < (me.movesAllowed ?? 1) ) return [choamPowerAction(g, kulon)!]; if (g.active !== me.id) return []; const shipmentAvailable = nexusGuildShipmentAvailable(g); const movementAvailable = nexusGuildMovementAvailable(g); const hajr = nexusGuildHajrAction(g); if (hajr) return [hajr]; const flightTargets = destinations(g).filter(target => target.score > -1000) .map(target => ({ territory: target.t, sector: target.s })); const skillCardMoves = botPlanetologistFlightActions(g, flightTargets, ['range3', 'twoGroups']); const cardMoves = sandmasterMovementCandidates(g, [...skillCardMoves, ...ornithopterMoves(g)]); const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked && !g.ornithopter?.active ? sandmasterMovementCandidates(g, [...botPlanetologistFlightActions(g, flightTargets, ['discovery']), ...discoveryFlightBotActions(g, flightTargets)]) : []; if (discoveryMoves.length && (me.shipped || me.reserves === 0)) return discoveryMoves; if ( cardMoves.length && (g.ornithopter?.active || me.shipped || me.reserves === 0) ) return cardMoves; if (g.ornithopter?.active && me.shipped) return [{ type: 'endMovement' }]; const actions: Action[] = [...guildHomeworldShipmentActions(g, level), ...homeworldShipmentActions(g, level)]; const targets = destinations(g); const groupGuildRate = groupGuildShipmentRate(g.players, me.id, g.coalitionState); const halfRate = g.karamaShipping?.player === me.id || (!g.guildRateCanceled && (me.faction === 'guild' || groupGuildRate || g.players.some((p) => p.faction === 'guild' && p.id === me.ally))); const shipmentBudget = (me.spice ?? 0) + g.aid.available; const guildSecret = nexusGuildSecretAllyCanAct(g); const noField = g.richeseNoField; const ownNoField = noField?.owner === me.id ? noField.private : null; const deployed = ownNoField?.deployed; const marker = deployed && isBoardNoFieldLocation(deployed.location) ? { ...deployed, location: deployed.location } : null; const deployedWorld = deployed && isHomeworldNoFieldLocation(deployed.location) ? deployed.location.homeworld : null; const deployedToken = ownNoField?.tokens.find( (token) => token.id === deployed?.tokenId, ); const materialized = Math.min(deployedToken?.value ?? 0, me.reserves); const physicalAlongsideMarker = marker ? countAt(me, marker.location.territory) > 0 : !!(deployedWorld && g.homeworlds?.worlds?.find(world => world.id === deployedWorld)?.forces[me.id]); const visitingMarker = !!marker && !!g.basicAlliedShipment?.noField && territory(marker.location.territory).name === g.basicAlliedShipment.territory; const reveal = noField?.canReveal && deployed && (materialized > 0 || physicalAlongsideMarker) ? { type: 'revealNoField', token: deployed.tokenId, event: noField.event } : null; if ( reveal && !visitingMarker && (physicalAlongsideMarker || !me.shipped || (me.moved ?? 0) >= (me.movesAllowed ?? 1)) ) return [reveal]; const markerTargets = targets.filter( (to) => to.t !== MOBILE_STRONGHOLD && !territoryEntryBlock(g.players, me.id, to.t), ); const markerShipmentTargets = targets.filter(to => to.t !== MOBILE_STRONGHOLD && !territoryEntryBlock(g.players, me.id, to.t, false, !g.advanced && isAlliedShipmentVisit(g, me, to.t))); if (noField?.canShip && ownNoField && !deployed && !me.shipped) { const tokens = ownNoField.tokens.filter( (token) => token.id !== ownNoField.lastShipped, ); const preferred = me.reserves === 0 ? 0 : level === 0 ? 3 : 5; const selected = [...tokens].sort( (a, b) => Math.abs(a.value - preferred) - Math.abs(b.value - preferred) || b.value - a.value, )[0]; const cunning = g.nexusRicheseCunning; if (cunning && !cunning.blocked && tokens.length >= 2) { const revealed = tokens.reduce((best, token) => token.value > best.value ? token : best); const concealed = tokens.find(token => token.id !== revealed.id)!; for (const amount of noField.homeworldForcesEnabled && me.reserves > 0 ? [1, 0] : [0]) { const group = quoteRicheseHomeworldPair({ enabled: !!noField.homeworldForcesEnabled, reserves: me.reserves, amount, revealedValue: revealed.value }); const target = markerTargets.slice(0, 24).find(to => reserveShipmentCost({ faction: me.faction, halfRate: g.karamaShipping?.player === me.id }, territory(to.t).type, group.priced) <= (me.spice ?? 0)); if (target) return [{ type: 'ship', noField: concealed.id, revealedToken: revealed.id, nexus: cunning.event, event: noField.event, territory: target.t, sector: target.s, allyPayment: 0, ...(amount ? { noFieldReserves: amount } : {}), }]; } } if (selected) for (const to of markerShipmentTargets.slice(0, 24)) { const smuggler = !!quoteSmugglerNoField(g, me.id, to.t); const companions = noField.homeworldForcesEnabled && me.reserves > (smuggler ? 1 : 0) ? [1, 0] : [0]; for (const amount of companions) { if (smuggler && isAlliedShipmentVisit(g, me, to.t)) continue; const group = quoteRicheseHomeworldForces({ enabled: !!noField.homeworldForcesEnabled, reserves: me.reserves, amount, smuggler }); if (Math.min(selected.value, me.reserves) + group.physical === 0 && to.enemy > 0) continue; const cost = reserveShipmentCost({ faction: me.faction, halfRate }, territory(to.t).type, group.priced); if (cost > shipmentBudget) continue; actions.push({ type: 'ship', noField: selected.id, event: noField.event, territory: to.t, sector: to.s, allyPayment: Math.max(0, cost - (me.spice ?? 0)), ...(smuggler ? { smuggler: true } : {}), ...(amount ? { noFieldReserves: amount } : {}), }); } } } if (marker && noField && !g.homeworldMobility?.noFieldMovementBlocked && (me.moved ?? 0) < (me.movesAllowed ?? 1)) { const from = location(marker.location.territory, marker.location.sector); const markerPlanetologist = !!planetologistLeader(g, me.id) && !g.ornithopter?.active && planetologistMovementModeSupported(g); let markerMoves = 0; if (marker.location.sector !== g.storm) for (const to of markerTargets) { if (from === to.key || (materialized === 0 && to.enemy > 0)) continue; const ordinary = botGroundMoveAllowed(g, me, from, to.key, 0); if (!ordinary && !(markerPlanetologist && botGroundMoveAllowed(g, me, from, to.key, 0, 'range'))) continue; if (visitingMarker && g.basicAlliedShipment && g.basicAlliedShipment.normal > 0) { let remaining = g.basicAlliedShipment.normal; const forces: Record = {}; for (const [key, total] of Object.entries(me.forces)) { if (splitLocation(key).territory !== marker.location.territory || !botGroundMoveAllowed(g, me, key, to.key, 0, ordinary ? undefined : 'range')) continue; const normal = total - (me.elites?.forces[key] ?? 0); const count = Math.min(remaining, normal); if (count > 0) { forces[key] = count; remaining -= count; } } if (!remaining) actions.push({ type: 'move', ...(!ordinary ? {planetologist: 'range'} : {}), forces, noField: marker.tokenId, event: noField.event, territory: to.t, sector: to.s }); } actions.push({ type: 'move', ...(!ordinary ? { planetologist: 'range' } : {}), forces: {}, noField: marker.tokenId, event: noField.event, territory: to.t, sector: to.s, }); // Bound legal choices after reachability, so unreachable high-ranked // destinations cannot hide the only usable ordinary/skill route. if (++markerMoves === 24) break; } } // Generate paid Guild-source candidates before applying ordinary prices or Fremen range. if (shipmentAvailable && guildSecret && me.reserves > 0) for (const to of targets.filter(to => botEntryAllowed(g, me, to.t, to.s, 'guildShip')).slice(0, 24)) { if (marker?.location.territory === to.t) continue; const desired = Math.min(me.reserves, Math.max([3, 4, 5, 6][level], to.enemy + 2)); for (const amount of new Set([desired, Math.min(desired, shipmentBudget * (territory(to.t).type === 'stronghold' ? 2 : 1))])) { const quote = nexusGuildSecretAllyQuote(g, to.t, amount); if (!quote || quote.cost > shipmentBudget || (me.faction === 'fremen' && fremenReserveEntry(to.t)) || (me.faction !== 'fremen' && quote.cost >= reserveShipmentCost({faction: me.faction, halfRate}, territory(to.t).type, amount))) continue; const candidate = nexusGuildSecretAllyAction(g, g.nexusGuildSecretAlly!.event, { type: 'ship', territory: to.t, sector: to.s, amount, elite: level > 0 ? Math.min(amount, me.elites?.reserves ?? 0) : Math.max(0, amount - (me.reserves - (me.elites?.reserves ?? 0))), }); if (candidate) actions.push(candidate); } } if (shipmentAvailable && me.reserves > 0) for (const to of destinations(g, g.advanced && me.faction === 'fremen') .filter( (to) => botEntryAllowed(g, me, to.t, to.s, 'ship') && (me.faction !== 'fremen' || fremenReserveEntry(to.t)), ) .slice(0, 24)) { const desired = Math.min( me.reserves, level === 0 ? (quoteSmugglerShipment(g, me.id, to.t, 2) ? 2 : 1) : Math.max([1, 3, 4, 5][level], to.enemy + 2), ); // Quote the discounted candidate independently of ordinary affordability. const richeseAmount = Math.min(me.reserves, 5, g.nexusRichese?.maxForces ?? 0); const richeseQuote = nexusRicheseQuote(g, to.t, richeseAmount); if (richeseQuote && richeseAmount > 1 && richeseQuote.cost <= shipmentBudget && richeseQuote.cost < reserveShipmentCost({ faction: me.faction, halfRate }, territory(to.t).type, richeseAmount)) { const candidate = nexusRicheseAction(g, g.nexusRichese!.event, { type: 'ship', territory: to.t, sector: to.s, amount: richeseAmount, elite: level > 0 ? Math.min(richeseAmount, me.elites?.reserves ?? 0) : Math.max(0, richeseAmount - (me.reserves - (me.elites?.reserves ?? 0))), }); if (candidate) actions.push(candidate); } for (const amount of new Set([desired, Math.min(desired, 3), ...(me.reserves >= 2 && quoteSmugglerShipment(g, me.id, to.t, 2) ? [2] : []), 1])) { if ( reserveShipmentCost( { faction: me.faction, halfRate }, territory(to.t).type, amount - (quoteSmugglerShipment(g, me.id, to.t, amount) ? 1 : 0), ) > shipmentBudget ) continue; actions.push({ type: 'ship', territory: to.t, sector: to.s, amount, ...(quoteSmugglerShipment(g, me.id, to.t, amount) ? { smuggler: true } : {}), ...(level > 0 ? { elite: Math.min(amount, me.elites?.reserves ?? 0) } : {}), }); } } const guildTransport = guildSecret || me.faction === 'guild' || groupGuildRate || g.players.some((p) => p.faction === 'guild' && p.id === me.ally); if (shipmentAvailable && guildTransport) for (const to of targets .filter((to) => botEntryAllowed(g, me, to.t, to.s, 'guildShip')) .slice(0, 16)) { if (marker?.location.territory === to.t) continue; if ( !guildSecret && me.faction === 'fremen' && me.reserves > 0 && guildTransportCost( to.t, Math.min(me.reserves, level === 0 ? 1 : 4), halfRate, ) <= shipmentBudget ) actions.push({ type: 'guildShip', from: 'reserves', territory: to.t, sector: to.s, amount: Math.min(me.reserves, level === 0 ? 1 : 4), }); for (const [from, n] of Object.entries(me.forces)) { const origin = gameTerritories(g).find( (t) => t.id === splitLocation(from).territory, )!; const amount = n - (level > 0 && origin.type === 'stronghold' && !isAdvisor(me, origin.id) ? 1 : 0); if ( amount > 0 && origin.id !== to.t && splitLocation(from).sector !== g.storm && botEntryAllowed(g, me, to.t, to.s, 'guildShip', origin.id) && guildTransportCost(to.t, amount, halfRate || guildSecret) <= shipmentBudget && gameDistance(g, from, to.key) > 3 ) actions.push({ type: 'guildShip', ...(guildSecret ? {nexus: g.nexusGuildSecretAlly!.event} : {}), from, territory: to.t, sector: to.s, amount, }); } } if (shipmentAvailable && guildSecret && !g.homeworlds?.worlds?.length) for (const [from, amount] of Object.entries(me.forces)) { const source = splitLocation(from); if (source.sector === g.storm || !g.players.some(p => p.id !== me.id && countAt(p, source.territory) > countAt(me, source.territory))) continue; const quote = nexusGuildSecretAllyQuote(g, 'reserves', amount); if (!quote || quote.cost > shipmentBudget) continue; const candidate = nexusGuildSecretAllyAction(g, g.nexusGuildSecretAlly!.event, {type:'guildShip', from, amount, territory:'reserves'}); if (candidate) actions.push(candidate); } const movingSources = Object.entries(me.forces) .map(([from, n]) => { const origin = splitLocation(from).territory; const retained = level > 0 && g.basicAlliedShipment?.territory !== territory(origin).name && !(g.advancedAllySeparation ?? g.basicAllySeparation)?.territories.includes(territory(origin).name) && territory(origin).type === 'stronghold' && !isAdvisor(me, origin) ? 1 : 0; const amount = n - retained; const availableElite = me.elites?.forces[from] ?? 0; const elite = level > 0 ? Math.min(amount, availableElite) : Math.max(0, amount - (n - availableElite)); return { from, amount, elite }; }) .filter((source) => source.amount > 0); const planetologist = planetologistLeader(g, me.id); const groundMoves: { action: Action; destinationScore: number; amount: number; gather: boolean; }[] = []; if (movementAvailable) for (const to of targets .filter( (to) => movingSources.some((source) => botGroundMoveAllowed(g, me, source.from, to.key, source.elite), ), ) .slice(0, 24)) for (const { from, amount, elite } of movingSources) { if (botGroundMoveAllowed(g, me, from, to.key, elite)) groundMoves.push({ action: { type: 'move', from, territory: to.t, sector: to.s, amount, elite, }, destinationScore: to.score, amount, gather: false, }); } if ( movementAvailable && planetologist && !g.ornithopter?.active && planetologistMovementModeSupported(g) ) { const usefulTargets = targets .filter( (to) => marker?.location.territory !== to.t && to.score > -1000, ); for (const to of usefulTargets .filter((target) => movingSources.some( ({ from, elite }) => botGroundMoveAllowed( g, me, from, target.key, elite, 'range', ) && !botGroundMoveAllowed(g, me, from, target.key, elite), ), ) .slice(0, 24)) for (const { from, amount, elite } of movingSources) { if ( botGroundMoveAllowed(g, me, from, to.key, elite, 'range') && !botGroundMoveAllowed(g, me, from, to.key, elite) ) groundMoves.push({ action: { type: 'move', planetologist: 'range', from, territory: to.t, sector: to.s, amount, elite, }, destinationScore: to.score, amount, gather: false, }); } if (marker && noField && !g.homeworldMobility?.noFieldMovementBlocked) { const markerFrom = location(marker.location.territory, marker.location.sector); for (const source of movingSources.filter(candidate => splitLocation(candidate.from).territory !== marker.location.territory).slice(0, 12)) for (const to of usefulTargets.filter(target => (materialized > 0 || target.enemy === 0) && botGroundMoveAllowed(g, me, markerFrom, target.key, 0, 'gather') && botGroundMoveAllowed(g, me, source.from, target.key, source.elite, 'gather')).slice(0, 24)) groundMoves.push({ action: { type: 'move', planetologist: 'gather', forces: { [source.from]: source.amount }, ...(me.elites ? { eliteForces: { [source.from]: source.elite } } : {}), noField: marker.tokenId, event: noField.event, territory: to.t, sector: to.s, }, destinationScore: to.score, amount: source.amount + 1, gather: true, }); } const gatherSources = movingSources.slice(0, 12); for (let first = 0; first < gatherSources.length; first += 1) for (let second = first + 1; second < gatherSources.length; second += 1) { const a = gatherSources[first], b = gatherSources[second], aTerritory = splitLocation(a.from).territory, bTerritory = splitLocation(b.from).territory; if (aTerritory === bTerritory) continue; for (const to of usefulTargets .filter( (target) => botGroundMoveAllowed( g, me, a.from, target.key, a.elite, 'gather', ) && botGroundMoveAllowed( g, me, b.from, target.key, b.elite, 'gather', ), ) .slice(0, 24)) { const entry = planetologistGatherAdvisorEntry(g, me, [aTerritory, bTerritory], to.t); if (entry.blocked) continue; groundMoves.push({ action: { type: 'move', planetologist: 'gather', ...(entry.requiresFlip ? { fighters: true } : {}), forces: { [a.from]: a.amount, [b.from]: b.amount }, ...(me.elites ? { eliteForces: { [a.from]: a.elite, [b.from]: b.elite, }, } : {}), territory: to.t, sector: to.s, }, destinationScore: to.score, amount: a.amount + b.amount, gather: true, }); } } } const sandmasterMoves = groundMoves.flatMap((candidate) => { const action = candidate.action; if ( action.type !== 'move' || typeof action.from !== 'string' || action.noField !== undefined || action.planetologist !== undefined || action.movementCard !== undefined || action.ornithopterEvent !== undefined || action.discoveryOrnithopter !== undefined ) return []; const choice = sandmasterDefaultChoice(g, me.id, action); return choice?.collect.length ? [ { ...candidate, action: { ...action, sandmaster: choice }, destinationScore: candidate.destinationScore + choice.collect.length, }, ] : []; }); groundMoves.push(...sandmasterMoves); groundMoves.sort( (a, b) => b.destinationScore - a.destinationScore || b.amount - a.amount || Number(b.gather) - Number(a.gather), ); actions.push(...groundMoves.map((candidate) => candidate.action)); // Moving before an unused shipment is legal; retain these candidates after // the existing shipment choices without changing their ordinary priority. actions.push(...skillCardMoves); actions.push(...cardMoves.filter(action => action.sandmaster !== undefined)); if (reveal && !actions.length) actions.push(reveal); const imperialMoves = emperorHomeworldMoveActions(g); if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip')) actions.unshift(...imperialMoves); else actions.push(...imperialMoves.filter(action => action.movementCard !== undefined || action.ornithopterEvent !== undefined)); const activeCunning = nexusGuildCunningActive(g); if (activeCunning?.stage === 'secondShipment' && activeCunning.hajrAvailable && me.hand?.some(card => card.effect === 'hajr') && !actions.some(action => ['ship', 'guildShip', 'homeworldShip', 'guildHomeworldShip'].includes(action.type))) { const skip = nexusGuildSkipShipmentAction(g, activeCunning.event); if (skip) actions.push(skip); } const cunning = g.nexusGuildCunning?.offer && nexusGuildCunningAction(g, g.nexusGuildCunning.offer.event); if (cunning && (me.reserves > 0 || Object.values(me.forces).some(n => n > 0))) actions.push(cunning); actions.push({ type: 'endMovement' }); return sandmasterMovementCandidates(g, g.ornithopter?.active ? actions.filter((action) => action.type !== 'move') : actions); } if (g.phase === 6) { const b = g.battle; const inspection = atreidesFullPlanAction(g); if (inspection) return [inspection]; if (b?.outcomeWindow && !b.outcomeWindow.closed) { if (![b.attacker, b.defender].includes(me.id) || b.outcomeWindow.ready.includes(me.id)) return []; return [{type: 'battleOutcomeReady', event: b.outcomeWindow.event}]; } const poison = g.residualPoison; const opponent = b?.attacker === me.id ? b.defender : b?.defender === me.id ? b.attacker : null; if ( poison && !poison.blocked && poison.event === b?.event && poison.target === opponent && opponent ) return [ { type: 'card', card: poison.card.id, target: opponent, event: poison.event, }, ]; if (b?.preLeader && !b.preLeader.closed) { if (!opponent || b.preLeader.ready.includes(me.id)) return []; return [{ type: 'battlePreparationReady', event: b.preLeader.event }]; } const preparation = b?.compliantPreparation?.actions[0]; if (preparation) return [preparation]; if (!b) return botBattleChoices(g); if (b.preparation) { if (b.preparation.owner !== me.id) return []; if (b.preparation.kind === 'nexusVoice') { const offer = g.nexusBorrowedVoice; if (!offer || offer.blocked) return []; const command = offer.choices.find(choice => !choice.must && choice.kind === 'shield'); return [{ type: 'nexusBorrowedVoice', event: offer.event, use: !!(offer.canUse && command), ...(offer.canUse && command ? { kind: command.kind, must: command.must } : {}) }]; } if (b.preparation.kind === 'voice') return level === 0 || b.preparation.blocked ? [{ type: 'declineBattlePower' }] : [ { type: 'voice', kind: variation(g, 'voice') > 0.5 ? 'shield' : 'snooper', must: false, }, ]; if (b.preparation.kind === 'prescience') { const target = b.preparation.beneficiary === b.attacker ? b.defender : b.attacker; return [ { type: 'prescience', field: level >= 2 || b.noFieldPlayers.includes(target) ? 'weapon' : 'dial', }, ]; } const nexus = b.preparation.kind === 'nexusPrescienceAnswer' ? b.nexusInspection : null; if ( b.preparation.kind === 'nexusPrescienceAnswer' && (!nexus || nexus.target !== me.id || nexus.event !== b.event || nexus.stage !== 'answer') ) return []; const field = nexus ? nexus.field : b.prescience!.field; // Candidate completions are legal-checked by the server; no rival secrets enter this choice. return plans(g).map((p) => ({ type: nexus ? 'nexusPrescienceAnswer' : 'prescienceAnswer', ...(nexus ? { event: nexus.event } : {}), value: battleCategoryInspectionValue(field, candidatePlan(p)[field], me.hand?.find(card => card.id === p[field])), })); } const ecazPower = g.ecazSpecialKarama; if (ecazPower && !ecazPower.blocked && !ecazPower.declared && ecazPower.event && ecazPower.cards.length && (me.hand ?? []).every(card => ['special', 'worthless', 'hero'].includes(card.kind))) return [{ type: 'card', mode: 'special', card: ecazPower.cards[0].id, event: ecazPower.event }]; if (b.revealed) { const ownsTraitorDecision = b.traitorVoters.includes(me.id) && !b.traitorSubmitted.includes(me.id); const beneficiary = b.traitorBeneficiary ?? ([b.attacker, b.defender].includes(me.id) ? me.id : me.ally); const enemy = b.attacker === beneficiary ? b.defender : b.attacker; const preferredCall = ownsTraitorDecision && !b.plans[enemy].kwisatz && !!matchingTraitor( me.traitors ?? [], b.plans[enemy].leader, b.cards.find((c) => c.id === b.plans[enemy].leader), ); const choices = b.traitorCallChoices; const call = choices && !choices.includes(preferredCall) ? choices[0] ?? false : preferredCall; const portable = g.portableSnooper; if ( !call && portable && !portable.blocked && portable.event === b.event && [b.attacker, b.defender].includes(me.id) ) { const card = (id: string | null) => b.cards.find((c) => c.id === id); const ownPlan = b.plans[me.id]; const ownLeader = g.allLeaders.find((l) => l.id === ownPlan.leader); const protectsKwisatzHero = !!ownPlan.kwisatz && card(ownPlan.leader)?.kind === 'hero'; const protection = (late: boolean) => { const defense = (id: string) => late && id === me.id ? portable.card : card(b.lateDefense[id] ?? b.plans[id].defense); const effects = strongholdBattleEffects( card(b.plans[b.attacker].weapon), defense(b.attacker), card(b.plans[b.defender].weapon), defense(b.defender), b.poisonTooth[b.attacker] ?? true, b.poisonTooth[b.defender] ?? true, b.strongholdEffects[b.attacker], b.strongholdEffects[b.defender], ); return me.id === b.attacker ? effects.attackerDead : effects.defenderDead; }; if ( (ownLeader || protectsKwisatzHero) && protection(false) && !protection(true) ) return [ { type: 'portableSnooper', card: portable.card.id, event: portable.event, }, ]; } if (!ownsTraitorDecision) return []; return [ { type: 'traitorCall', call, }, ]; } if ( b.fullPlan && !b.submitted.includes(b.fullPlan.target) && b.fullPlan.target !== me.id ) return []; return [b.attacker, b.defender].includes(me.id) && !b.submitted.includes(me.id) ? plans(g) : []; } if (g.phase === 8) { const gamont = gamontAction(g); if (gamont) return [gamont]; } if ( g.phase === 8 && me.faction === 'choam' && !g.inflationUsed && !g.inflationAttempted ) { const doubleFirst = level === 0 ? variation(g, 'inflation') > 0.5 : (me.spice ?? 0) < 12 || me.tanks > me.reserves; return [ { type: 'choamInflation', side: doubleFirst ? 'double' : 'cancel' }, ]; } if (g.phase === 8 && me.faction === 'tleilaxu' && !me.faceDancerReplaced) { const candidate = me.faceDancers?.find( (c) => !c.revealed && g.players.flatMap((p) => p.leaders).find((l) => l.id === c.leader) ?.dead, ); if (candidate) return [{ type: 'replaceFaceDancer', leader: candidate.leader }]; } return g.ready.includes(me.id) ? [] : [{ type: 'ready' }]; } /** A voluntary recovery before ordinary passing or movement. Specific decisions, * free/paid revival actions and authoritative preparation witnesses keep priority. */ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null { const me = g.players.find((p) => p.id === g.me)!; const options = g.ghola; const duringSale = choamSaleGholaTiming(g); if ( !(me.bot ?? me.autopilot) || g.status !== 'playing' || !options?.available || g.battle || (g.response && !duringSale) || g.decision || g.truthtrance || g.phaseOpening || g.automaticContinuationPending || g.auction || g.richeseAuction || g.phase <= 1 || (g.ready.includes(me.id) && !duringSale) || ([5, 6].includes(g.phase) && g.active !== me.id && !duringSale) || g.shipmentCompletion?.actions.length || (!duringSale && ordinary[0] && ![ 'ready', 'ship', 'guildShip', 'homeworldShip', 'guildHomeworldShip', 'junctionShip', 'move', 'endMovement', 'chooseBattle', 'passBid', ].includes(ordinary[0].type)) ) return null; const card = me.hand?.find( (candidate) => candidate.effect === 'ghola' && options.cards.includes(candidate.id), ); if (!card) return null; const level = rank(g); const living = me.leaders.filter( (leader) => !leader.dead && controlsLeader(me, leader), ); const leader = [...options.leaders].sort( (a, b) => b.strength - a.strength || a.id.localeCompare(b.id), )[0]; const bestLiving = Math.max( 0, ...living.map((candidate) => candidate.strength), ); if ( leader && (living.length <= [1, 2, 2, 2][level] || (level > 0 && leader.strength > bestLiving)) ) return { type: 'card', card: card.id, leader: leader.id }; if (options.kwisatz && living.length) return { type: 'card', card: card.id, leader: 'kwisatz' }; const amount = options.maxForces; if ( amount > 0 && (amount >= [3, 4, 4, 5][level] || me.reserves === 0) && me.reserves <= [5, 7, 9, 10][level] ) { const minimumElite = Math.max( 0, amount - (me.tanks - (me.elites?.tanks ?? 0)), ); const elite = level === 0 ? minimumElite : Math.min(amount, options.eliteRemaining); if (elite >= minimumElite) return { type: 'card', card: card.id, amount, elite }; } return null; } /** Obligations apply across policy branches, including choosing to move first. */ function botAdvisorMatchingAllowed(g:GameView,action:Action) { if(!g.advisorMatchingDenied?.length || !['move','ship'].includes(action.type)) return true; const me=g.players.find(p=>p.id===g.me)!; if(!g.advanced || me.faction!=='beneGesserit' || g.phase!==5 || g.active!==me.id) return true; const destination=String(action.territory),sector=Number(action.sector); const group:Record=action.type==='ship' ? {reserves:Number(action.amount)} : action.forces && typeof action.forces==='object' ? action.forces as Record : {[String(action.from)]:Number(action.amount)}; const origins=[...new Set(Object.keys(group).map(key=>splitLocation(key).territory))]; if(action.type==='move'&&action.planetologist==='gather'){ try{ quoteAdvisorGatherMatching(g,{owner:me.id,move:me.moved??0,destination,sector, advisors:isAdvisor(me,destination),group});return true; }catch(error){if(!(error instanceof AdvisorGatherMatchingError))throw error;return false;} } if(action.type==='move' && origins.length!==1) return true; try { quoteAdvisorMatching(g,{kind:action.type==='ship'?'shipment':'movement',owner:me.id, move:action.type==='ship'?0:me.moved??0, origin:action.type==='ship'?'reserves':origins[0],destination,sector, advisors:isAdvisor(me,destination),group}); return true; } catch(error) { if(error instanceof AdvisorMatchingError) return false; throw error; } } export function botActions(g: GameView): Action[] { if (g.victoryContinuation) return g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending ? policyActions(g) : []; if (g.homeworldTupileBlocked) return []; const dealPayer = g.players.find(player => player.id === g.me); if ((dealPayer?.bot || dealPayer?.autopilot) && g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) { const payable = Object.entries(g.deferredDealPaymentOptions ?? {}).find(([id,option]) => { const payment = g.deals.find(deal => deal.id === id)?.payment; return !option.blocked && payment?.kind === 'deferredBribe' && payment.dueTurn <= g.turn; }); if (payable) return [{type:'dealPay',deal:payable[0]}]; } if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup) return tupileCleanupActions(g); if (g.turnActionGuide && g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) return [g.turnActionGuide.action]; if (g.movementExpressionPromiseGuide && g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) return [g.movementExpressionPromiseGuide.action]; if (g.travelPromiseGuide && g.status === 'playing' && g.phase >= 0 && g.phase <= 5 && (g.phase < 5 || g.active === g.me) && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) return [g.travelPromiseGuide.action]; if (g.movementPromisePolicy && g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) return g.movementPromisePolicy; if (g.shipmentPromisePolicy && g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) return g.shipmentPromisePolicy; if (g.nexusHarkonnenBetrayalReaction) { if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed || g.automaticContinuationPending) return []; // Native counter and existing private decisions still own their source. if (g.response || g.truthtrance || g.decision) return policyActions(g); return botNexusHarkonnenBetrayalActions(g); } if (g.nexusIxianBetrayalReaction) { if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed || g.automaticContinuationPending) return []; // Original native counters and existing private decisions retain priority. if (g.response || g.truthtrance || g.decision) return policyActions(g); return botNexusIxianBetrayalActions(g); } if (g.guildBetrayalReaction) return botNexusGuildBetrayalActions(g); if (g.richeseBetrayalReaction) return botNexusRicheseBetrayalActions(g); if (g.kullReaction) return botChoamKullActions(g); if (g.kullCounterEvent) return botChoamKullCounterActions(g); if (g.semutaReaction) return botSemutaActions(g); const replacement = g.nexusIxianReplacement; if (replacement) { const own = g.players.find((player) => player.id === g.me); if (!(own?.bot ?? own?.autopilot) || g.status !== 'playing' || g.phase !== 3 || g.roomControl?.paused || g.roomControl?.closed || g.automaticContinuationPending || g.truthtrance || g.response || g.phaseOpening || g.nexusCards?.waiting.length || g.nexusTraitors?.pending || !replacement.event || replacement.buyer !== g.me || !replacement.canPass || (g.decision && (g.decision.kind !== 'nexusIxianReplacement' || g.decision.player !== g.me || g.decision.event !== replacement.event))) return []; // Value only the authorized purchased card; the runtime owns custody, // eligibility, original payment and the native continuation. const use = replacement.canUse && !replacement.blocked && replacement.purchased && (replacement.purchased.kind === 'worthless' || technologyCardValue(g, replacement.purchased) < (rank(g) === 0 ? 0.5 : 4)); return [{ type: use ? 'nexusIxianReplacementUse' : 'nexusIxianReplacementPass', event: replacement.event }]; } if (g.decision?.kind === 'nexusIxianReplacement') return []; const fremenRevival = nexusFremenRevivalAction(g); if (fremenRevival) return [fremenRevival]; const emperorRevival = g.nexusEmperorSecretAlly?.revival; if (emperorRevival && !emperorRevival.blocked && emperorRevival.eliteOptions.length) { const own = g.players.find((player) => player.id === g.me); const elite = own?.bot === 'Easy' ? Math.min(...emperorRevival.eliteOptions) : Math.max(...emperorRevival.eliteOptions); const action = nexusEmperorRevivalAction(g, elite); if (action) return [action]; } const emperorPurchase = nexusEmperorPurchaseAction(g); if (emperorPurchase) return [emperorPurchase]; const bureaucrat = g.bureaucrat?.pending; if (bureaucrat) { if (bureaucrat.owner !== g.me || g.decision?.kind !== 'bureaucratPayment' || g.decision.player !== g.me || g.decision.event !== bureaucrat.event || g.truthtrance || g.response || g.automaticContinuationPending) return []; const ally = g.players.find((player) => player.id === g.me)?.ally; return [{ type: 'decision', event: bureaucrat.event, redirect: bureaucrat.payee !== ally }]; } const mentat = g.mentat?.pending; if (mentat) { if (mentat.player !== g.me || g.decision?.kind !== 'mentatQuestion' || g.decision.player !== g.me || g.decision.event !== mentat.event || g.truthtrance || g.response || g.automaticContinuationPending) return []; if (mentat.stage === 'name') { if (mentat.blocked || !mentat.weapons.length) return [{ type: 'decision', event: mentat.event, decline: true }]; const preferred = rank(g) === 0 ? mentat.weapons : ['Poison Tooth', 'Lasgun', 'Crysknife', 'Chaumas', ...mentat.weapons]; const weapon = preferred.find((name) => mentat.weapons.includes(name))!; return [{ type: 'decision', event: mentat.event, weapon }]; } const card = [...mentat.cards].sort((a, b) => technologyCardValue(g, a) - technologyCardValue(g, b) || a.id.localeCompare(b.id), )[0]; return card ? [{ type: 'decision', event: mentat.event, card: card.id }] : []; } const skill = g.leaderSkills; if (skill && !g.truthtrance && !g.response && !g.phaseOpening) { if (skill.battleChoice) return [{type:'leaderSkillVisibility',event:skill.battleChoice.event,hide:true}]; if (skill.offer && ((g.status === 'setup' && g.setupStage === 'leaderSkills') || (g.decision?.kind === 'leaderSkillRevival' && g.decision.player === g.me))) { const offer = skill.offer; if (!offer.cards.length) return [{type:'leaderSkill',event:offer.event,mode:'draw'}]; const available = offer.cards.filter((card) => !skill.unavailableSkills?.[card]); const direct = ['warmaster','master-of-assassins','swordmaster-of-ginaz','killer-medic','prana-bindu-adept']; const chosen = available.find((card) => direct.includes(card)) ?? available[0]; const leader = offer.leader ?? [...skill.eligibleLeaders].sort((a,b) => (g.allLeaders.find((l) => l.id === b.id)?.strength ?? 0) - (g.allLeaders.find((l) => l.id === a.id)?.strength ?? 0))[0]?.id; if (leader && chosen) return [{type:'leaderSkill',event:offer.event,skill:chosen,leader}]; } } if (g.nexusTraitors?.pending) return g.truthtrance ? policyActions({ ...g, decision: null }) : nexusTraitorBotActions(g); if (g.automaticContinuationPending) return []; const recruits = recruitsPlayAction(g.recruitsPreview); if (recruits) { const own = g.players.find((player) => player.id === g.me)!; const rates = g.recruitsPreview!.rates; const ownRate = rates.find((rate) => rate.player === own.id)!; const ally = g.players.find((player) => player.id === own.ally); const allyRate = rates.find((rate) => rate.player === ally?.id); const freeBenefit = ownRate.freeRate > 0 && own.tanks > g.revival.freeRemaining; const paidBenefit = ownRate.limit < 7 && own.tanks > g.revival.forcesRemaining && (own.spice ?? 0) >= 2; const allyBenefit = ally && allyRate && allyRate.freeRate > 0 && ally.tanks > allyRate.freeRate; if (freeBenefit || paidBenefit || allyBenefit) return [recruits]; } if (g.nexusCards?.waiting.length) return nexusCardBotActions(g); const discoveryEntry = discoveryEntryBotActions(g); if (discoveryEntry.length) return discoveryEntry; const discovery = discoveryBotActions(g); if (discovery.length) return discovery; const trade = nexusChoamTradeBotActions(g); if (trade.length) return trade; const choamBetrayal = nexusChoamBetrayalBotActions(g); if (choamBetrayal.length) return choamBetrayal; const moritaniBetrayal = moritaniBetrayalBotActions(g); if (moritaniBetrayal.length) return moritaniBetrayal; const ecazBetrayal = ecazBetrayalBotActions(g); if (ecazBetrayal.length) return ecazBetrayal; const fremenBetrayal = fremenBetrayalBotActions(g); if (fremenBetrayal.length) return fremenBetrayal; const ecazInquiry = ecazInquiryBotActions(g); if (ecazInquiry.length) return ecazInquiry; const sardaukar = nexusSardaukarBotActions(g); if (sardaukar.length) return sardaukar; const advisorConversion = nexusAdvisorBotActions(g); if (advisorConversion.length) return advisorConversion; const suboids = nexusSuboidBotActions(g); if (suboids.length) return suboids; const faceDancers = nexusTleilaxuBotActions(g); if (faceDancers.length) return faceDancers; const traitorExchange = nexusTraitorBotActions(g); if (traitorExchange.length) return traitorExchange; const ecazDuke = nexusEcazDukeBotActions(g); if (ecazDuke.length) return ecazDuke; const nexus = g.nexusAtreides; if ( g.status === 'playing' && g.phase === 6 && nexus && !nexus.blocked && g.nexusCards?.card === 'atreides' && nexus.event === g.battle?.event && !g.truthtrance && !g.phaseOpening && !g.decision && (nexus.mode === 'betrayal' || !g.response) ) { if (nexus.mode === 'betrayal') return [{ type: 'nexusAtreides', event: nexus.event, mode: nexus.mode }]; const preferences = nexus.mode === 'cunning' || rank(g) >= 2 ? ['weapon', 'defense', 'leader', 'dial'] as const : ['dial', 'weapon', 'defense', 'leader'] as const; const field = preferences.find((field) => nexus.fields.includes(field)); if (field) return [{ type: 'nexusAtreides', event: nexus.event, mode: nexus.mode, field }]; } const shrineTruth = shrineTruthtranceBotAction( g, g.players.find((player) => player.id === g.me)!, ); if (shrineTruth) return [shrineTruth]; const intelligence = tupileIntelligenceActions(g); if (intelligence.length) return intelligence; const actions = [...junctionTransportActions(g, rank(g)), ...guildNoFieldTransportActions(g), ...policyActions(g)].flatMap((action) => { if (action.type === 'move' && nexusGuildCunningActive(g) && !nexusGuildMovementAvailable(g)) return []; if (action.type === 'battlePlan' && g.ecazSpecialKarama?.declared && (action.weapon || action.defense)) return []; const sourced = withNativeShipmentSources(g, action); return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) && botAdvisorMatchingAllowed(g,sourced) && (sourced.type!=='guildShip'||!g.advisorTransportMatchingDenied?.length||!guildTransportQuote(g,sourced).unavailableReasons.length) ? [sourced] : []; }); const me = g.players.find((p) => p.id === g.me)!; const advancedDepartures = (g.advancedAllySeparation ?? g.basicAllySeparation)?.territories; if ((g.basicAlliedShipment || advancedDepartures?.length) && g.phase === 5 && g.active === me.id && me.shipped && me.moved === 0) { // Depart from shared groups before unrelated moves; do not retain a // sentinel that the ending alliance constraint would send to Tanks. const marker = g.richeseNoField?.private?.deployed; const markerTerritory = marker && isBoardNoFieldLocation(marker.location) ? territory(marker.location.territory).name : null; const departures = actions.filter(action => action.type === 'move' && ( typeof action.from === 'string' && (territory(splitLocation(action.from).territory).name === g.basicAlliedShipment?.territory || advancedDepartures?.includes(territory(splitLocation(action.from).territory).name)) || !!action.noField && !!g.basicAlliedShipment?.noField && markerTerritory === g.basicAlliedShipment.territory)); if (g.basicAlliedShipment?.noField && g.basicAlliedShipment.normal > 0) { const together = departures.filter(action => action.noField && action.forces && typeof action.forces === 'object' && !Array.isArray(action.forces) && Object.values(action.forces).reduce((sum, value) => sum + (typeof value === 'number' ? value : 0), 0) >= g.basicAlliedShipment!.normal); if (together.length) return together; } if (departures.length) return departures; } const ghola = standaloneGholaAction(g, actions); if (ghola && !homeworldRevivalActionBlock(g, ghola)) return [ghola]; if ( g.phase !== 5 || g.active !== me.id || !nexusGuildShipmentAvailable(g) || g.truthtrance || g.response || g.decision || g.phaseOpening ) return actions; const promises = liveShipmentPromises( g.shipmentPromises ?? [], me.id, g.turn, ); if (!promises.length) return actions; const filtered = actions.filter((action) => { if ( ![ 'ship', 'guildShip', 'homeworldShip', 'guildHomeworldShip', 'junctionShip', 'move', 'emperorHomeworldMove', 'endMovement', ].includes(action.type) ) return true; const junctionNative = action.type === 'junctionShip' && typeof action.destination === 'string' && !action.destination.startsWith('homeworld:') && !!action.sources && Object.keys(action.sources as object).every((source) => g.homeworlds?.worlds?.some((world) => world.id === source && world.native === me.id)); const fromReserves = action.type === 'ship' || (action.type === 'guildShip' && action.from === 'reserves'); const shipment = junctionNative ? {territory: splitLocation(String(action.destination)).territory, sector: splitLocation(String(action.destination)).sector, amount: Object.values(action.sources as Record).reduce((n, source) => n + source.normal + source.elite, 0)} : fromReserves ? { territory: String(action.territory), sector: Number(action.sector), amount: Number(action.amount) } : null; return promises.every( (promise) => matchesShipment(promise, shipment) === promise.answer, ); }); const witness = g.shipmentCompletion?.actions[0]; const mustShip = promises.some(promise => matchesShipment(promise,null) !== promise.answer); if (mustShip && witness) { // A completion is a sequence, not interchangeable alternatives. Reproject // after its first preparation before offering the later funded shipment. return [ witness, ...filtered.filter( (action) => JSON.stringify(action) !== JSON.stringify(witness), ), ]; } return filtered.length || mustShip ? filtered : [{ type: 'endMovement' }]; } export function runBots(state: Game, limit = 96): Game { const supplier = state.players.find((p) => p.faction === 'richese'); const unopenedAllyWindow = state.phase === 5 && supplier?.noField && supplier.ally === state.active && !( state.richeseAllyOpportunity?.turn === state.turn && state.richeseAllyOpportunity.recipient === supplier.ally ); let g = state.pendingTreacheryDiscard || state.response || unopenedAllyWindow ? normalizeAutomaticGame(state) : structuredClone(state); for (let step = 0; step < limit; step++) { let next: Game | undefined; const actors = g.players.filter((p) => p.bot ?? p.autopilot); // Private Nexus plays must run before an earlier-seated bot consumes // their shared shipment or pre-battle opening. const ecazDukeHolder = g.phase === 6 && !g.battle && !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && g.nexusCards?.cards ? actors.find(actor => actor.faction === 'ecaz' && !actor.ally && g.nexusCards!.cards!.hands[actor.id] === 'ecaz') : undefined; const ecazHolder = g.phase === 5 && g.nexusCards?.cards ? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'ecaz' && ecazBetrayalOffer(g, actor.id)?.blocked === null) : undefined; const fremenHolder = (g.phase === 1 || g.phase === 5) && g.nexusCards?.cards ? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'fremen' && fremenBetrayalOffer(g, actor.id)?.blocked === null) : undefined; const priorityNexus = ecazDukeHolder ?? ecazHolder ?? fremenHolder; if (priorityNexus) actors.sort((a, b) => Number(b.id === priorityNexus.id) - Number(a.id === priorityNexus.id)); else if (g.phase === 5 && junctionSponsor(g) && !currentJunctionOffer(g)) actors.sort((a, b) => Number(b.faction === 'guild') - Number(a.faction === 'guild')); for (const p of actors) { for (const action of botActions(viewGame(g, p.id))) { try { next = applyAction(g, p.id, action); break; } catch (error) { if (!(error instanceof RuleError)) throw error; } } if (next) break; } if (!next) { g.botsPending = false; return g; } g = next; if (g.status === 'finished') { g.botsPending = false; return g; } } g.botsPending = true; return g; }