4089 lines
151 KiB
TypeScript
4089 lines
151 KiB
TypeScript
import { discoveryBotActions } from './discovery-options';
|
|
import { recruitsPlayAction } from './recruits';
|
|
import type { Card } from './cards';
|
|
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat';
|
|
import { smugglerBattleModeSupported, 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 { atomicsAllianceChangeBlocked } from './moritani-atomics';
|
|
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 {
|
|
canUseAsKaramaRole,
|
|
shrineTruthtranceBotAction,
|
|
} from './shrine';
|
|
import { reserveShipmentCost } from './shipment-price';
|
|
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,
|
|
fremenReserveEntry,
|
|
guildTransportCost,
|
|
} from './bot-mobility';
|
|
import { planetologistMovementModeSupported, planetologistLeader } from './planetologist-movement';
|
|
import { sandmasterDefaultChoice } from './sandmaster-movement';
|
|
import { sandmasterWormCollection } from './sandmaster-worm';
|
|
import { richeseCardDefinition } from './richese-cards';
|
|
import { presenceAt } from './force-presence';
|
|
import { botArrivalBlock, botWormRideArrivalBlock } from './bot-arrival';
|
|
import { botSemutaActions } from './bot-semuta';
|
|
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
|
|
import { fighterCount, isAdvisor } from './advisors';
|
|
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
|
import { stoneBurnerPlanBlock, stoneBurnerComparison } 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 level = rank(g);
|
|
const alliedNoField = g.richeseNoField;
|
|
if (
|
|
alliedNoField?.owner === me.id &&
|
|
alliedNoField.canOfferAlly &&
|
|
!alliedNoField.allyDeclined &&
|
|
alliedNoField.private
|
|
) {
|
|
const inventory = alliedNoField.private;
|
|
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' ? 1 : 2) <= (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);
|
|
candidates.push({
|
|
score,
|
|
action: {
|
|
type: 'decision',
|
|
event: entry.event,
|
|
forces,
|
|
eliteForces,
|
|
territory: destination.territory,
|
|
sector: destination.sector,
|
|
...(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 pathsBlocked = (key: string) => {
|
|
const loc = splitLocation(key);
|
|
return (
|
|
loc.sector === g.storm ||
|
|
strongholdPathBlocked(g.players, me.id, loc.territory)
|
|
);
|
|
};
|
|
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 ||
|
|
isAdvisor(me, 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 = gameDistance(g, source.from, to.key, pathsBlocked);
|
|
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 {
|
|
return {
|
|
...(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,
|
|
};
|
|
}
|
|
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 typedForces = g.advanced || me.faction === 'ixians';
|
|
const ownStrength = typedForces
|
|
? maxCombatDial(forces)
|
|
: own;
|
|
const supportCache = new Map<
|
|
number,
|
|
{ support: number; cost: number } | null
|
|
>();
|
|
const supporting = (dial: number) => {
|
|
if (supportCache.has(dial)) return supportCache.get(dial)!;
|
|
const choices = Array.from(
|
|
{
|
|
length:
|
|
(g.advanced
|
|
? Math.min(
|
|
maxCombatSupport(forces),
|
|
(me.spice ?? 0) +
|
|
g.aid.available +
|
|
(b.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0),
|
|
)
|
|
: 0) + 1,
|
|
},
|
|
(_, support) =>
|
|
casualtyOptions(forces, 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;
|
|
supportCache.set(dial, result);
|
|
return result;
|
|
};
|
|
// 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) =>
|
|
(!isMirrorWeapon(card) || !mirrorWeaponModeBlock(g)) &&
|
|
(card.id !== 'ecaz-reinforcements' || (!!b.reinforcements && !b.reinforcements.blocked)) &&
|
|
(card.id !== 'ecaz-harass-withdraw' || (!!b.harassWithdraw && !b.harassWithdraw.blocked));
|
|
const weapons = [
|
|
null,
|
|
...(me.hand ?? []).filter(c => prototypeCardAllowed(c) && battleCardSlotEligible('weapon', c, {
|
|
planetologistWeapon: 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) && 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 &&
|
|
!(b.smugglerCollectionEnabled && usesSurvivingSkilledLeader(battleSkills, 'smuggler', leader, true)) &&
|
|
!!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 }, smugglerBattleModeSupported(g),
|
|
{ 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 reinforcements = weapon === 'ecaz-reinforcements' || defense === 'ecaz-reinforcements';
|
|
if (reinforcements && (weapon === 'ecaz-harass-withdraw' ||
|
|
defense === 'ecaz-harass-withdraw' || isStoneBurner(w))) 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,
|
|
}).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 ideal = 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),
|
|
),
|
|
);
|
|
for (const dial of new Set([
|
|
ideal,
|
|
...bankSupportedDials,
|
|
...freeEliteDials,
|
|
...[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',
|
|
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;
|
|
const commitment = typedForces
|
|
? supporting(Number(action.dial))
|
|
: null;
|
|
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;
|
|
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',
|
|
),
|
|
))
|
|
)
|
|
continue;
|
|
let withdrawn = 0;
|
|
const harass = weapon === 'ecaz-harass-withdraw' || defense === 'ecaz-harass-withdraw';
|
|
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 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 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.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 === '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 &&
|
|
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 =
|
|
!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])
|
|
return [
|
|
{
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: specialCard.id,
|
|
route: route.route,
|
|
},
|
|
];
|
|
}
|
|
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 === '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;
|
|
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion' || d.kind === 'bureaucratPayment') return [];
|
|
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 === '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 = gameTerritories(g).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 === '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 === 'ixRicheseTechnology')
|
|
return [{ type: 'decision', event: d.event, decline: true }];
|
|
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 === 'richeseCache') {
|
|
const cards = [...(g.richeseBidding?.cache ?? [])].sort(
|
|
(a, b) => technologyCardValue(g, a) - technologyCardValue(g, b),
|
|
);
|
|
return cards.length
|
|
? [
|
|
{
|
|
type: 'decision',
|
|
event: g.richeseBidding!.event,
|
|
card: cards[0].id,
|
|
method: level === 0 ? 'silent' : 'onceAround',
|
|
direction: 'clockwise',
|
|
},
|
|
]
|
|
: [];
|
|
}
|
|
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 === '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' || d.kind === 'ixAuction') {
|
|
const cards =
|
|
d.kind === 'ixSetup' ? g.ixTechnology?.setup : g.ixTechnology?.pool;
|
|
const sorted = [...(cards ?? [])].sort(
|
|
(a, b) => technologyCardValue(g, b) - technologyCardValue(g, a),
|
|
);
|
|
if (!sorted.length) return [];
|
|
return [
|
|
{
|
|
type: 'decision',
|
|
card: (d.kind === 'ixSetup' ? sorted[0] : sorted.at(-1)!).id,
|
|
position: 'bottom',
|
|
},
|
|
];
|
|
}
|
|
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) => ({ type: 'decision', route: r.route })),
|
|
{ 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
|
|
)
|
|
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)
|
|
: 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 === '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 ?? [];
|
|
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',
|
|
).winner
|
|
: stoneBurnerComparison(
|
|
pool,
|
|
other.dial,
|
|
other.support,
|
|
b.ownForces!,
|
|
own.dial,
|
|
own.support,
|
|
b.tieWinner === b.attacker ? 'attacker' : 'defender',
|
|
).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')
|
|
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 === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g);
|
|
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 (to.t === source || !botEntryAllowed(g, me, to.t, to.s, 'move') ||
|
|
territoryEntryBlock(g.players, me.id, to.t)) continue;
|
|
candidates.push({ type: 'decision', event: d.event, accept: true,
|
|
source, forces, territory: to.t, sector: to.s });
|
|
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 }) => ({ type: 'decision', event: d.event, choice }));
|
|
}
|
|
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) =>
|
|
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 === 'battleCards')
|
|
return [
|
|
{
|
|
type: 'decision',
|
|
discard: d.cards.filter(
|
|
(id) => me.hand?.find((c) => c.id === id)?.kind === 'worthless',
|
|
),
|
|
},
|
|
];
|
|
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 => ({
|
|
type: 'decision',
|
|
accept: true,
|
|
territory: to.t,
|
|
sector: to.s,
|
|
...(sandmasterWormCollection(g, me.id, to.t, to.s)?.blocked === null
|
|
? { sandmasterCollect: true } : {}),
|
|
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]
|
|
) {
|
|
// Until the other cache effects are enabled, acquire only the usable
|
|
// canonical Karama. Keep a spice reserve and never inspect a 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,
|
|
},
|
|
];
|
|
}
|
|
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 &&
|
|
!atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, player.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] &&
|
|
!atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, 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];
|
|
if (
|
|
request &&
|
|
!request.declined &&
|
|
request.price !== null &&
|
|
request.price <= (me.spice ?? 0)
|
|
)
|
|
actions.push({ type: 'acceptLeaderRevival' });
|
|
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 = nexusGuildCunningActive(g) ? [] : 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 &&
|
|
countAt(me, to.t) === 0 &&
|
|
!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)) {
|
|
if (marker?.location.territory === to.t) continue;
|
|
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 &&
|
|
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) =>
|
|
marker?.location.territory !== to.t &&
|
|
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 ||
|
|
(g.advanced &&
|
|
me.faction === 'beneGesserit' &&
|
|
isAdvisor(me, aTerritory) !== isAdvisor(me, 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)) {
|
|
groundMoves.push({
|
|
action: {
|
|
type: 'move',
|
|
planetologist: 'gather',
|
|
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;
|
|
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 === 'voice')
|
|
return level === 0
|
|
? [{ 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])),
|
|
}));
|
|
}
|
|
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.semutaReaction) return botSemutaActions(g);
|
|
const fremenRevival = g.nexusFremenRevival;
|
|
if (fremenRevival && !fremenRevival.blocked && fremenRevival.eliteOptions.length) {
|
|
const own = g.players.find(player => player.id === g.me);
|
|
const elite = own?.bot === 'Easy'
|
|
? Math.min(...fremenRevival.eliteOptions)
|
|
: Math.max(...fremenRevival.eliteOptions);
|
|
const action = nexusFremenRevivalAction(g, elite);
|
|
if (action) return [action];
|
|
}
|
|
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 [];
|
|
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 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];
|
|
if (promises.some((promise) => promise.answer) && 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 || promises.some((promise) => promise.answer)
|
|
? 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;
|
|
}
|