246 lines
8.7 KiB
TypeScript
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;
|
|
}
|
|
}
|