import type { Action, GameView } from './engine'; import { arrivalAsAdvisor } from './advisors'; import { splitLocation, territory, validLocation } from './board'; 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(); if (action.noField !== undefined) { const marker = me.noField?.deployed; if (me.faction !== 'richese' || !marker) 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; } }