Files
Dune/game/bots.ts
T
Quintine f995b6f4cd Support earlier classic Basic movement promises
Allow current-round classic Basic questions before Movement, with owned Charity, ordinary auction payment and Revival preparation. Reopen readiness, retain combined shipment obligations, reset stale allowances and release forced storm impossibility. Current-phase guidance and minimal bots preserve the route; held Karama now prepares its actual Guild shipment rate.

Validation: affected rules/control tests, types/lint, production build and four gameplay/control/table-rendering programmes. Native early invoices, wider preparations/modules, full rules and deployed acceptance remain open. No saved-game, database, restart or deployment operation.
2026-10-24 15:49:35 +11:00

4391 lines
170 KiB
TypeScript

import { discoveryBotActions } from './discovery-options';
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 {
emperorHomeworldMoveActions,
withNativeShipmentSources,
} from './homeworld-options';
import { matchesPlanClaim, respectsBattlePromises } from './battle-promises';
import { ecazOccupancyRelation } from './ecaz-occupy';
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
import { strongholdBattleEffects } from './stronghold-battle';
import {
eliteRevivalRemaining,
forceRevivalPrice,
paidForceRevivalCost,
} from './revival';
import { controlsLeader, nativeAvailable } from './leader-control';
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 { 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 { quoteSandmasterStronghold, sandmasterDefaultChoice, sandmasterDestinationCollection } 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 { 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<string, number> = {};
const eliteForces: Record<string, number> = {};
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;
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 marker = noField?.private?.deployed;
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;
}
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<number, SupportChoice | null>();
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<number, SupportChoice | null>() };
});
// 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.length || (voice?.kind === 'hero' && !voice.must));
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]),
)) ranked.push({ type: 'battlePlan', ...b.compliantPlan });
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, ...option };
}
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<string>();
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.homeworldTupileBlocked) return [];
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.truthtrance) {
const w = g.truthtrance;
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 === '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 === '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.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 elite = Math.min(
me.elites?.tanks ?? 0,
eliteRevivalRemaining(me, g.advanced),
);
const amount = Math.min(3, me.tanks - (me.elites?.tanks ?? 0) + elite);
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 = TERRITORIES.find(
(t) =>
t.type === 'stronghold' &&
t.id !== MOBILE_STRONGHOLD &&
!t.sectors.includes(g.storm) &&
!ambassadors.tokens.some(
(a) => a.zone === 'placed' && a.location === t.id,
),
);
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.bounty-a.bounty || 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 };
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 &&
(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 = Math.min(
revival.eliteTanks,
Math.max(0, 1 - revival.eliteRevived),
);
const forces = Math.min(revival.maximum, revival.tanks);
return [{ ...action, forces, elite: Math.min(elite, forces) }];
}
if (entry.stage === 'offer') {
if (entry.effect === 'ecaz')
return [
entry.allianceOffer && !entry.allianceOffer.blocked
? {
...action,
trigger: true,
beneficiary: entry.owner,
choice: 'alliance',
}
: entry.dukeAcquisition && !entry.dukeAcquisition.blocked
? {
...action,
trigger: true,
beneficiary: entry.owner,
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<string, number> = {};
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));
if (specialCard && shouldStop && !g.revival.specialKaramaBlock)
return [
{
type: 'card',
mode: 'special',
card: specialCard.id,
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<string, number> = 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') {
const b = g.battle!;
const beneficiary = [b.attacker, b.defender].includes(me.id)
? me.id
: me.ally;
if (
specialCard &&
beneficiary &&
[b.attacker, b.defender].includes(beneficiary)
)
return [
{
type: 'card',
mode: 'special',
card: specialCard.id,
target: b.attacker === beneficiary ? b.defender : b.attacker,
},
];
return [{ 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<typeof me.hand>[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' && g.guildAmbassadorAdvisorChoices) {
const choices = 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)))
// 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 === '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 }];
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<string, { normal: number; elite: number }> = {};
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 ||
(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 thumper = me.hand?.find((c) => c.effect === 'thumper');
const prior = g.spiceDiscardTop[0];
if (
thumper &&
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 &&
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 && !me.ally) {
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],
);
if (target) return [{ type: 'alliance', target: target.id }];
}
}
return g.ready.includes(me.id) ? [] : [{ type: 'ready' }];
}
if (
g.phase === 2 &&
!me.charityClaimed &&
!g.charity.incomePending &&
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 [];
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) {
if (g.revival.prevented)
return g.ready.includes(me.id) ? [] : [{ type: 'ready' }];
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 (me.faction === 'fremen' && me.ally && !g.freeRevival.includes(me.ally) &&
!g.recruitsPreview?.grantRevivalBlocked)
actions.push({ type: 'grantRevival' });
if (me.faction === 'emperor' && me.ally && level > 0) {
const ally = g.players.find((p) => p.id === me.ally)!;
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, (me.spice ?? 0) - 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) <= (me.spice ?? 0) - 3)
actions.push({ type: 'emperorRevival', amount, elite });
}
}
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,
);
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.revivalRequests[me.id]?.price === null &&
!g.revivalRequests[me.id]?.declined
)
)
actions.push({ type: 'ready' });
return actions;
}
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 cardMoves = ornithopterMoves(g);
const discoveryMoves = g.discoveryOrnithopter && !g.discoveryOrnithopter.blocked
? discoveryFlightBotActions(g,
destinations(g).filter(target => target.score > -1000)
.map(target => ({ territory: target.t, sector: target.s })))
: [];
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 halfRate = g.karamaShipping?.player === me.id ||
(!g.guildRateCanceled &&
(me.faction === 'guild' ||
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 marker = ownNoField?.deployed;
const deployedToken = ownNoField?.tokens.find(
(token) => token.id === marker?.tokenId,
);
const materialized = Math.min(deployedToken?.value ?? 0, me.reserves);
const physicalAlongsideMarker = marker
? countAt(me, marker.location.territory) > 0
: false;
const reveal =
noField?.canReveal &&
marker &&
(materialized > 0 || physicalAlongsideMarker)
? { type: 'revealNoField', token: marker.tokenId, event: noField.event }
: null;
if (
reveal &&
(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),
);
if (noField?.canShip && ownNoField && !marker && !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)!;
const target = markerTargets.slice(0, 24).find(to =>
reserveShipmentCost({ faction: me.faction, halfRate: false },
territory(to.t).type, 1) <= (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,
}];
}
if (selected)
for (const to of markerTargets.slice(0, 24)) {
if (Math.min(selected.value, me.reserves) === 0 && to.enemy > 0)
continue;
const cost = reserveShipmentCost(
{ faction: me.faction, halfRate },
territory(to.t).type,
1,
);
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)),
...(quoteSmugglerNoField(g, me.id, to.t) ? { smuggler: true } : {}),
});
}
}
if (marker && noField && !g.homeworldMobility?.noFieldMovementBlocked && (me.moved ?? 0) < (me.movesAllowed ?? 1)) {
const from = location(marker.location.territory, marker.location.sector);
const range =
fighterCount(me, 'arrakeen') || fighterCount(me, 'carthag') ? 3 : 1;
if (marker.location.sector !== g.storm)
for (const to of markerTargets.slice(0, 24)) {
if (from === to.key || (materialized === 0 && to.enemy > 0)) continue;
if (
g.balisetRestrictions.some(
(b) => b.player === me.id && b.territory === to.t,
) &&
marker.location.territory !== to.t
)
continue;
if (
gameDistance(g, from, to.key, (key) => {
const loc = splitLocation(key);
return (
loc.sector === g.storm ||
strongholdPathBlocked(g.players, me.id, loc.territory)
);
}) > range
)
continue;
actions.push({
type: 'move',
forces: {},
noField: marker.tokenId,
event: noField.event,
territory: to.t,
sector: to.s,
});
}
}
// 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' ||
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?.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,
});
}
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));
if (reveal && !actions.length) actions.push(reveal);
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
actions.unshift(...emperorHomeworldMoveActions(g));
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 g.ornithopter?.active
? actions.filter((action) => action.type !== 'move')
: actions;
}
if (g.phase === 6) {
const b = g.battle;
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.attacker, b.defender].includes(me.id)
? me.id
: me.ally;
const enemy = b.attacker === beneficiary ? b.defender : b.attacker;
const call =
ownsTraitorDecision &&
!b.plans[enemy].kwisatz &&
!!matchingTraitor(
me.traitors ?? [],
b.plans[enemy].leader,
b.cards.find((c) => c.id === b.plans[enemy].leader),
);
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. */
export function botActions(g: GameView): Action[] {
if (g.homeworldTupileBlocked) return [];
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
return tupileCleanupActions(g);
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)), ...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) ? [sourced] : [];
});
const me = g.players.find((p) => p.id === g.me)!;
const advancedDepartures = g.advancedAllySeparation?.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 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)));
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,
amount: Object.values(action.sources as Record<string, {normal: number; elite: number}>).reduce((n, source) => n + source.normal + source.elite, 0)}
: fromReserves
? { territory: String(action.territory), 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;
}