Files
Dune/game/bot-arrival.ts
T

246 lines
8.7 KiB
TypeScript

import type { Action, GameView } from './engine';
import { arrivalAsAdvisor } from './advisors';
import { splitLocation, territory, validLocation } from './board';
import { isBoardNoFieldLocation } from './richese-no-field';
import {
MovementArrivalError,
quoteCompletedMovementArrival,
type CompletedMovementArrivalInput,
} from './karama-movement-preflight';
import {
guildShipmentCost,
reserveShipmentCost,
shipmentPaymentBounds,
} from './shipment-price';
import { quoteSmugglerShipment } from './smuggler-shipment';
/**
* Known arrival support for ordinary owner-view candidates, plus the public
* overlap guard for Homeworld-sourced shipments and single-source Guild
* transport. This is not a complete action validator. Explicit Nexus and
* special movement routes retain their adapters.
* No hidden Terror face or rival hand enters this quote; unexpected failures
* are never treated as rejected candidates.
*/
export function botArrivalBlock(g: GameView, action: Action): string | null {
if (g.status === 'playing' && g.phase === 5 && g.active === g.me &&
action.type === 'guildShip' && action.nexus === undefined && g.guildTransportCanceled)
return 'Karama prevents native Guild cross/return transport for this shipment opportunity.';
if (
g.status !== 'playing' ||
g.phase !== 5 ||
g.active !== g.me ||
!['ship', 'guildShip', 'move'].includes(action.type) ||
action.nexus !== undefined ||
action.source !== undefined ||
typeof action.territory !== 'string' ||
typeof action.sector !== 'number' ||
!validLocation(action.territory, action.sector)
)
return null;
const me = g.players.find((p) => p.id === g.me);
if (!me) return null;
const to = action.territory;
const controls = {
response: !!g.response,
decision: !!g.decision,
pendingTerror: !!g.terrorEntry,
pendingAmbassador: !!g.ambassadorEntry,
paidBox: g.decision?.kind === 'nullentropy',
};
let order: CompletedMovementArrivalInput['order'];
if (action.type === 'ship' && g.moritaniAtomics?.territory === to)
return 'Atomics Aftermath permanently blocks shipments into this territory.';
if (action.type === 'guildShip') {
if (
action.forces !== undefined ||
typeof action.from !== 'string' ||
typeof action.amount !== 'number' ||
!Number.isSafeInteger(action.amount) ||
action.amount < 1
) return null;
const origin = action.from === 'reserves' ? 'reserves' : splitLocation(action.from);
if (origin !== 'reserves' && !validLocation(origin.territory, origin.sector))
return null;
const cost = guildShipmentCost(territory(to).type, action.amount,
g.karamaShipping?.player === me.id || !g.guildRateCanceled);
const bounds = shipmentPaymentBounds(cost, me.spice ?? 0, g.aid.available);
const allyPayment = action.allyPayment ?? bounds.minimum;
if (typeof allyPayment !== 'number' || !Number.isSafeInteger(allyPayment) ||
allyPayment < bounds.minimum || allyPayment > bounds.maximum)
return null;
order = {
player: me.id,
origin: origin === 'reserves' ? origin : origin.territory,
to, advisors: arrivalAsAdvisor(g, me, to), wantsFighters: false,
};
} else if (g.homeworlds || action.homeworldSources !== undefined) {
if (action.type !== 'ship' || !g.homeworlds || action.homeworldSources === undefined)
return null;
// Off-planet Homeworld pools have the same arrival triggers. Only the
// public reaction overlap is quoted here; the engine owns source and price.
order = {
player: me.id,
origin: 'homeworld',
to,
advisors: arrivalAsAdvisor(g, me, to),
wantsFighters: false,
};
} else if (action.type === 'ship') {
const amount = action.noField === undefined ? action.amount : 1;
if (
typeof amount !== 'number' ||
!Number.isSafeInteger(amount) ||
amount < 1 ||
(action.noField !== undefined && me.faction !== 'richese')
)
return null;
const guild = g.players.find((p) => p.faction === 'guild');
const halfRate =
g.karamaShipping?.player === me.id ||
(!g.guildRateCanceled &&
(me.faction === 'guild' || guild?.id === me.ally));
// A No-Field's concealed value and optional companion never change its price.
const smuggler =
action.noField === undefined && action.smuggler === true
? quoteSmugglerShipment(g, me.id, to, amount)
: null;
const cost = reserveShipmentCost(
{ faction: me.faction, halfRate },
territory(to).type,
amount - (smuggler ? 1 : 0),
);
const bounds = shipmentPaymentBounds(cost, me.spice ?? 0, g.aid.available);
const allyPayment = action.allyPayment ?? bounds.minimum;
if (
typeof allyPayment !== 'number' ||
!Number.isSafeInteger(allyPayment) ||
allyPayment < bounds.minimum ||
allyPayment > bounds.maximum
)
return null;
// Ordinary shipment income settles before its deferred territory entry.
controls.response ||=
(g.advanced && me.faction === 'fremen' && action.sector === g.storm);
order = {
player: me.id,
origin: 'reserves',
to,
advisors: arrivalAsAdvisor(g, me, to),
wantsFighters: false,
};
} else {
if (
g.ornithopter?.active ||
[
'movementCard',
'ornithopter',
'ornithopterEvent',
'planetologist',
'sandmaster',
'discoveryOrnithopter',
'discoveryFlight',
'origins',
].some((key) => action[key] !== undefined)
)
return null;
let entries: [string, unknown][];
if (action.forces !== undefined) {
if (
!action.forces ||
typeof action.forces !== 'object' ||
Array.isArray(action.forces)
)
return null;
entries = Object.entries(action.forces);
} else {
if (typeof action.from !== 'string') return null;
entries = [[action.from, action.amount]];
}
const origins = new Set<string>();
if (action.noField !== undefined) {
const marker = me.noField?.deployed;
if (me.faction !== 'richese' || !marker || !isBoardNoFieldLocation(marker.location)) return null;
origins.add(marker.location.territory);
}
for (const [key, count] of entries) {
if (
typeof count !== 'number' ||
!Number.isSafeInteger(count) ||
count < 0
)
return null;
if (!count) continue;
const source = splitLocation(key);
if (!validLocation(source.territory, source.sector)) return null;
origins.add(source.territory);
}
if (origins.size !== 1) return null;
const origin = [...origins][0];
const advisors = arrivalAsAdvisor(g, me, to, origin);
order = {
player: me.id,
origin,
origins: [...origins],
to,
advisors,
wantsFighters: advisors && action.fighters === true,
};
}
try {
quoteCompletedMovementArrival({
advanced: g.advanced,
overlapSupported: g.arrivalOverlapMode === true,
players: g.players,
order,
ambassadors: (g.ambassadors?.tokens ?? []).map(
({ zone, location, effect }) => ({ zone, location, effect }),
),
terror: (g.moritaniTerror?.tokens ?? []).map(({ status, location }) => ({
status,
location,
})),
controls,
flight: null,
});
return null;
} catch (error) {
if (error instanceof MovementArrivalError) return error.message;
throw error;
}
}
/** The worm decision itself is replaced before entry reactions; only a new
* intrusion or another pending control conflicts with the destination.
* Homeworld riders retain their separate unsupported-combination policy.
*/
export function botWormRideArrivalBlock(g: GameView, to: string, sector: number): string | null {
const ride = g.decision;
if (g.status !== 'playing' || g.phase !== 1 || ride?.kind !== 'wormRide' ||
ride.player !== g.me || g.homeworlds || !validLocation(to, sector))
return null;
try {
quoteCompletedMovementArrival({
advanced: g.advanced,
overlapSupported: g.arrivalOverlapMode === true,
players: g.players,
order: { player: g.me, origin: ride.territory, to,
advisors: false, wantsFighters: false },
ambassadors: (g.ambassadors?.tokens ?? []).map(
({ zone, location, effect }) => ({ zone, location, effect })),
terror: (g.moritaniTerror?.tokens ?? []).map(
({ status, location }) => ({ status, location })),
controls: {
response: !!g.response, decision: false,
pendingTerror: !!g.terrorEntry, pendingAmbassador: !!g.ambassadorEntry,
paidBox: false,
},
flight: null,
});
return null;
} catch (error) {
if (error instanceof MovementArrivalError) return error.message;
throw error;
}
}