Apply the Advanced retained-fighter target cutover through shared quotes and controls. Preserve committed foreign shipments while handling free-packet shipping, Polar matching, actual placement and original arrival once.
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
import type { Action, Game, Player } from './engine';
|
|
import { arrivalAsAdvisor } from './advisors';
|
|
import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival } from './spiritual-advisor';
|
|
import {
|
|
gameTerritories,
|
|
location,
|
|
MOBILE_STRONGHOLD,
|
|
splitLocation,
|
|
TERRITORIES,
|
|
validGameLocation,
|
|
} from './board';
|
|
import { presenceAt } from './force-presence';
|
|
import { territoryEntryBlock } from './occupancy';
|
|
import { homeworldSpiritualAdvisorQuote } from './homeworld-mobility';
|
|
import type { GuildAmbassadorMarker } from './guild-ambassador-no-field';
|
|
|
|
export class GuildAmbassadorShipmentError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'GuildAmbassadorShipmentError';
|
|
}
|
|
}
|
|
export type GuildAmbassadorShipment = {
|
|
player: string;
|
|
amount: number;
|
|
elite: number;
|
|
territory: string;
|
|
sector: number;
|
|
advisors: boolean;
|
|
cost: 0 | 1 | 2;
|
|
allyPayment: 0;
|
|
marker?: GuildAmbassadorMarker;
|
|
};
|
|
export type GuildAmbassadorShipments = {
|
|
maximum: number;
|
|
eliteReserves: number;
|
|
destinations: { territory: string; sector: number; advisors: boolean }[];
|
|
};
|
|
function requireShipment(value: unknown, message: string): asserts value {
|
|
if (!value) throw new GuildAmbassadorShipmentError(message);
|
|
}
|
|
function count(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
}
|
|
function record(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
function usableLocation(g: Game, key: string) {
|
|
const at = splitLocation(key);
|
|
return (
|
|
location(at.territory, at.sector) === key &&
|
|
validGameLocation(g, at.territory, at.sector) &&
|
|
(at.territory !== MOBILE_STRONGHOLD || !!g.mobileStronghold?.location)
|
|
);
|
|
}
|
|
function printedLocation(key: string) {
|
|
const at = splitLocation(key);
|
|
return TERRITORIES.some(
|
|
(territory) =>
|
|
territory.id === at.territory && territory.sectors.includes(at.sector),
|
|
);
|
|
}
|
|
/** Only public board custody and the beneficiary's physical reserves are read.
|
|
* The caller binds the Ambassador, event, actor and continuation timing. */
|
|
function beneficiary(g: Game, id: string) {
|
|
requireShipment(
|
|
Array.isArray(g.players) &&
|
|
g.players.length > 0 &&
|
|
new Set(g.players.map((p) => p.id)).size === g.players.length,
|
|
'Shipment requires unique seated players.',
|
|
);
|
|
requireShipment(
|
|
count(g.storm) && g.storm <= 18,
|
|
'The storm sector is invalid.',
|
|
);
|
|
requireShipment(
|
|
!g.mobileStronghold?.location ||
|
|
(usableLocation(g, g.mobileStronghold.location) &&
|
|
printedLocation(g.mobileStronghold.location)),
|
|
'The mobile stronghold must point to a board location.',
|
|
);
|
|
for (const p of g.players) {
|
|
requireShipment(
|
|
typeof p.id === 'string' &&
|
|
p.id.length > 0 &&
|
|
(!p.ally ||
|
|
g.players.some((other) => other.id === p.ally && other.id !== p.id)),
|
|
'The player or alliance is invalid.',
|
|
);
|
|
requireShipment(
|
|
record(p.forces) &&
|
|
(!p.elites || (record(p.elites) && record(p.elites.forces))),
|
|
'Shipment requires valid physical force custody.',
|
|
);
|
|
const totals: Record<string, number> = {};
|
|
for (const [key, n] of Object.entries(p.forces)) {
|
|
requireShipment(
|
|
usableLocation(g, key) && count(n),
|
|
'Invalid board force location or quantity.',
|
|
);
|
|
const t = splitLocation(key).territory;
|
|
totals[t] = (totals[t] ?? 0) + n;
|
|
requireShipment(
|
|
count(totals[t]),
|
|
'The territory force quantity is invalid.',
|
|
);
|
|
}
|
|
for (const [key, n] of Object.entries(p.elites?.forces ?? {}))
|
|
requireShipment(
|
|
usableLocation(g, key) && count(n) && n <= (p.forces[key] ?? 0),
|
|
'Elite forces must be part of the physical force inventory.',
|
|
);
|
|
const marker = p.noField?.deployed;
|
|
requireShipment(
|
|
!marker ||
|
|
(usableLocation(
|
|
g,
|
|
location(marker.location.territory, marker.location.sector),
|
|
) &&
|
|
count((totals[marker.location.territory] ?? 0) + 1)),
|
|
'The concealed marker has an invalid public location.',
|
|
);
|
|
requireShipment(
|
|
!p.advisors || record(p.advisors),
|
|
'The advisor inventory is invalid.',
|
|
);
|
|
for (const [t, stance] of Object.entries(p.advisors ?? {}))
|
|
requireShipment(
|
|
gameTerritories(g).some((candidate) => candidate.id === t) &&
|
|
record(stance) &&
|
|
(stance.lockedTurn === undefined || count(stance.lockedTurn)),
|
|
'The advisor stance is invalid.',
|
|
);
|
|
}
|
|
const p = g.players.find((candidate) => candidate.id === id);
|
|
requireShipment(p, 'The beneficiary is not seated.');
|
|
requireShipment(
|
|
count(p.reserves) &&
|
|
count(p.elites?.reserves ?? 0) &&
|
|
(p.elites?.reserves ?? 0) <= p.reserves,
|
|
'The physical or elite reserves are invalid.',
|
|
);
|
|
return p;
|
|
}
|
|
function validateDestinationOccupancy(
|
|
g: Game,
|
|
p: Player,
|
|
to: string,
|
|
advisors: boolean,
|
|
) {
|
|
try {
|
|
const blocked = territoryEntryBlock(g.players, p.id, to, advisors);
|
|
requireShipment(!blocked, blocked ?? 'This destination cannot be entered.');
|
|
} catch (error) {
|
|
if (error instanceof GuildAmbassadorShipmentError) throw error;
|
|
throw new GuildAmbassadorShipmentError(
|
|
error instanceof Error ? error.message : 'Invalid territory occupancy.',
|
|
);
|
|
}
|
|
}
|
|
/** E3's independent free reserve shipment. No ordinary shipment turn, funding,
|
|
* faction distance, reserve-region or incoming accompaniment is resolved here.
|
|
* No-Field substitution is deliberately not admitted by this physical quote.
|
|
* E3 Atomics Aftermath also forbids this shipment. The current Game has no
|
|
* Aftermath location state: its eventual producer must supply that public
|
|
* exclusion here before the unfinished Atomics module can be enabled. */
|
|
export function quoteGuildAmbassadorShipment(
|
|
g: Game,
|
|
beneficiaryId: string,
|
|
action: Action,
|
|
): GuildAmbassadorShipment {
|
|
const p = beneficiary(g, beneficiaryId);
|
|
requireShipment(
|
|
action.noField === undefined && action.alliedNoField === undefined,
|
|
'This quote supports physical reserve forces only, not No-Field substitution.',
|
|
);
|
|
const amount = action.amount;
|
|
requireShipment(
|
|
count(amount) && amount >= 1 && amount <= Math.min(4, p.reserves),
|
|
'Ship between one and four available reserve forces.',
|
|
);
|
|
const eliteReserves = p.elites?.reserves ?? 0;
|
|
const minimum = Math.max(0, amount - (p.reserves - eliteReserves));
|
|
const elite = action.elite === undefined ? minimum : action.elite;
|
|
requireShipment(
|
|
count(elite) &&
|
|
elite >= minimum &&
|
|
elite <= Math.min(amount, eliteReserves),
|
|
'Choose available elites within the physical reserve shipment.',
|
|
);
|
|
const to = action.territory,
|
|
sector = action.sector;
|
|
requireShipment(
|
|
typeof to === 'string' &&
|
|
count(sector) &&
|
|
usableLocation(g, location(to, sector)),
|
|
'Choose a sector of an available board territory.',
|
|
);
|
|
requireShipment(
|
|
sector === 0 || sector !== g.storm,
|
|
'The shipment destination is in storm.',
|
|
);
|
|
requireShipment(
|
|
to !== MOBILE_STRONGHOLD || p.faction === 'ixians',
|
|
'Only Ixians may ship directly into the mobile stronghold.',
|
|
);
|
|
const advisors = arrivalAsAdvisor(g, p, to);
|
|
validateDestinationOccupancy(g, p, to, advisors);
|
|
const target = location(to, sector);
|
|
requireShipment(
|
|
count((p.forces[target] ?? 0) + amount) &&
|
|
count(presenceAt(p, to) + amount) &&
|
|
count((p.elites?.forces[target] ?? 0) + elite),
|
|
'The resulting force quantity is invalid.',
|
|
);
|
|
return {
|
|
player: p.id,
|
|
amount,
|
|
elite,
|
|
territory: to,
|
|
sector,
|
|
advisors,
|
|
cost: 0,
|
|
allyPayment: 0,
|
|
};
|
|
}
|
|
/** Every listed destination has a one-force legal witness. Final quantities and
|
|
* elite choices are always checked by the quote, never inferred from this list. */
|
|
export function guildAmbassadorShipments(
|
|
g: Game,
|
|
beneficiaryId: string,
|
|
): GuildAmbassadorShipments {
|
|
const p = beneficiary(g, beneficiaryId);
|
|
const result: GuildAmbassadorShipments = {
|
|
maximum: Math.min(4, p.reserves),
|
|
eliteReserves: p.elites?.reserves ?? 0,
|
|
destinations: [],
|
|
};
|
|
if (!result.maximum) return result;
|
|
for (const t of gameTerritories(g))
|
|
for (const sector of t.sectors) {
|
|
try {
|
|
const quoted = quoteGuildAmbassadorShipment(g, beneficiaryId, {
|
|
type: 'decision',
|
|
amount: 1,
|
|
territory: t.id,
|
|
sector,
|
|
});
|
|
result.destinations.push({
|
|
territory: t.id,
|
|
sector,
|
|
advisors: quoted.advisors,
|
|
});
|
|
} catch (error) {
|
|
if (!(error instanceof GuildAmbassadorShipmentError)) throw error;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export type GuildAmbassadorAdvisor = {
|
|
player: string;
|
|
territory: string;
|
|
sector: number;
|
|
amount: 1 | 2;
|
|
elite: 0;
|
|
advisors: boolean;
|
|
};
|
|
|
|
/** Quote the separate BG reserve arrival after this completed shipment. The
|
|
* caller binds the parent event and stage; primary forces and alliances may
|
|
* already have changed through Terror. No payment, placement or RNG occurs. */
|
|
export function quoteGuildAmbassadorAdvisor(
|
|
g: Game,
|
|
bgId: string,
|
|
shipment: GuildAmbassadorShipment,
|
|
action: Action,
|
|
recordedLegacy = false,
|
|
): GuildAmbassadorAdvisor {
|
|
const bg = beneficiary(g, bgId);
|
|
const shipper = g.players.find((p) => p.id === shipment?.player);
|
|
requireShipment(
|
|
bg.faction === 'beneGesserit' &&
|
|
g.players.filter((p) => p.faction === 'beneGesserit').length === 1 &&
|
|
shipper &&
|
|
shipper.id !== bg.id &&
|
|
shipper.faction !== 'fremen' &&
|
|
record(shipment) &&
|
|
count(shipment.amount) &&
|
|
(shipment.amount >= 1 || shipment.marker?.mode === 'allied') &&
|
|
shipment.amount <= 4 &&
|
|
count(shipment.elite) &&
|
|
shipment.elite <= shipment.amount &&
|
|
(shipment.marker ? [0,1,2].includes(shipment.cost) : shipment.cost === 0) &&
|
|
shipment.allyPayment === 0 &&
|
|
typeof shipment.advisors === 'boolean' &&
|
|
typeof shipment.territory === 'string' &&
|
|
count(shipment.sector) &&
|
|
validGameLocation(g, shipment.territory, shipment.sector) &&
|
|
!Object.hasOwn(shipment, 'noField') &&
|
|
!Object.hasOwn(shipment, 'alliedNoField'),
|
|
'BG accompaniment needs the completed physical Guild Ambassador shipment by another off-planet faction.',
|
|
);
|
|
requireShipment(
|
|
bg.reserves - (bg.elites?.reserves ?? 0) >= 1,
|
|
'No ordinary Bene Gesserit reserve force remains for accompaniment.',
|
|
);
|
|
requireShipment(
|
|
(action.accompany === undefined || typeof action.accompany === 'boolean') &&
|
|
(action.sector === undefined ||
|
|
(count(action.sector) && action.sector <= 18)),
|
|
'Choose a valid accompaniment option and sector.',
|
|
);
|
|
const accompanying = action.accompany === true && g.advanced;
|
|
const to = accompanying ? shipment.territory : 'polar_sink';
|
|
const sector = accompanying
|
|
? action.sector === undefined
|
|
? shipment.sector
|
|
: (action.sector as number)
|
|
: 0;
|
|
const allowance = homeworldSpiritualAdvisorQuote(g, bg.id, to);
|
|
requireShipment(
|
|
!allowance.blocked,
|
|
allowance.blocked ?? 'Accompaniment is unavailable.',
|
|
);
|
|
const amount = action.amount === undefined ? 1 : action.amount;
|
|
requireShipment(
|
|
(amount === 1 || amount === 2) &&
|
|
amount <= allowance.maximum &&
|
|
amount <= bg.reserves - (bg.elites?.reserves ?? 0),
|
|
'Choose an available spiritual-advisor amount; two requires high-population Wallach IX and Polar Sink.',
|
|
);
|
|
requireShipment(
|
|
(action.territory === undefined || action.territory === to) &&
|
|
(action.elite === undefined || action.elite === 0) &&
|
|
action.noField === undefined &&
|
|
action.alliedNoField === undefined &&
|
|
usableLocation(g, location(to, sector)) &&
|
|
(sector === 0 || sector !== g.storm),
|
|
'Accompaniment sends ordinary forces only to the shipment territory or Polar Sink, outside storm.',
|
|
);
|
|
// E1 p.10 FAQ explicitly allows BG to accompany Ixians into the HMS. This
|
|
// grants no independent BG direct shipment or relocation of the stronghold.
|
|
requireShipment(
|
|
to !== MOBILE_STRONGHOLD || (accompanying && shipper.faction === 'ixians'),
|
|
'Accompany only an Ixian shipment into the mobile stronghold.',
|
|
);
|
|
const targetBlock=recordedLegacy?null:spiritualAdvisorTargetBlock(g,bg.id,to);
|
|
requireShipment(!targetBlock,targetBlock??'This Spiritual Advisor target is unavailable.');
|
|
const advisors = recordedLegacy ? arrivalAsAdvisor(g, bg, to, undefined, accompanying)
|
|
: spiritualAdvisorArrival(g,bg.id,to).advisors;
|
|
validateDestinationOccupancy(g, bg, to, advisors);
|
|
requireShipment(
|
|
count((bg.forces[location(to, sector)] ?? 0) + amount) &&
|
|
count(presenceAt(bg, to) + amount),
|
|
'The resulting Bene Gesserit force quantity is invalid.',
|
|
);
|
|
return {
|
|
player: bg.id,
|
|
territory: to,
|
|
sector,
|
|
amount,
|
|
elite: 0,
|
|
advisors,
|
|
};
|
|
}
|