189 lines
7.5 KiB
TypeScript
189 lines
7.5 KiB
TypeScript
import { MOBILE_STRONGHOLD, splitLocation } from './board';
|
|
import { ecazOccupancyRelation } from './ecaz-occupy';
|
|
import { presenceAt, type ForcePresence } from './force-presence';
|
|
import type { OccupancySeat } from './occupancy';
|
|
import { coalitionAllies, coalitionGroups, type CoalitionState } from './coalition-alliance';
|
|
import { FACTIONS } from './catalog';
|
|
|
|
/** Physical baseline distinguishes this turn's visitors from old co-occupation. */
|
|
export type BasicAlliedShipment = Readonly<{
|
|
player: string;
|
|
ally: string;
|
|
/** Present allies at arrival for a three-member group; absent in legacy pair receipts. */
|
|
allies?: readonly string[];
|
|
turn: number;
|
|
territory: string;
|
|
baseline: Readonly<{ normal: number; elite: number }>;
|
|
/** Private token identity for a newly shipped concealed visitor. */
|
|
noField?: string;
|
|
}>;
|
|
|
|
type ShipmentContext = Readonly<{
|
|
phase: number;
|
|
active: string | null;
|
|
players: readonly OccupancySeat[];
|
|
advanced?: boolean;
|
|
coalitionState?: CoalitionState;
|
|
}>;
|
|
type ShipmentPlayer = OccupancySeat & Readonly<{
|
|
shipped?: boolean;
|
|
moved?: number;
|
|
elites?: { forces: Readonly<Record<string, number>> };
|
|
}>;
|
|
|
|
function currentShipmentAllies(context: ShipmentContext, player: OccupancySeat): string[] {
|
|
if (context.coalitionState === undefined) {
|
|
const ally = context.players.find(seat => seat.id === player.ally);
|
|
return ally?.ally === player.id ? [ally.id] : [];
|
|
}
|
|
const seats = context.players.map(seat => ({
|
|
id: seat.id, ally: seat.ally ?? null, allySinceTurn: seat.allySinceTurn,
|
|
}));
|
|
const groups = coalitionGroups(seats, context.coalitionState);
|
|
if (groups.some(group => group.members.length >= 3) &&
|
|
(context.players.some(seat => seat.faction === 'ecaz') || context.advanced &&
|
|
context.players.some(seat => !FACTIONS.some(faction => faction.id === seat.faction && faction.expansion === 'base'))))
|
|
throw new Error('Larger alliance shipment rules require a supported roster without Ecaz.');
|
|
return coalitionAllies(seats, context.coalitionState, player.id);
|
|
}
|
|
|
|
function presentShipmentAllies(context: ShipmentContext, player: OccupancySeat, to: string,
|
|
allies: readonly string[]): string[] {
|
|
return allies.filter(id => {
|
|
const ally = context.players.find(seat => seat.id === id);
|
|
return !!ally && presenceAt(ally, to) > 0 &&
|
|
ecazOccupancyRelation(context.players, player.id, ally.id, {
|
|
kind: 'territory', id: to,
|
|
}) !== 'ecazAlliance';
|
|
});
|
|
}
|
|
|
|
/** Ordinary reserve arrivals only; movement endpoints keep their guard. */
|
|
export function isAlliedShipmentVisit(
|
|
context: ShipmentContext,
|
|
player: ShipmentPlayer,
|
|
to: string,
|
|
): boolean {
|
|
return alliedShipmentWitnesses(context, player, to).length > 0;
|
|
}
|
|
|
|
/** Eligible allies currently at a temporary shipment destination. */
|
|
export function alliedShipmentWitnesses(context: ShipmentContext, player: ShipmentPlayer, to: string): string[] {
|
|
const allies = currentShipmentAllies(context, player);
|
|
if (context.phase !== 5 || context.active !== player.id ||
|
|
player.shipped !== false || player.moved !== 0 || to === 'polar_sink' ||
|
|
to === MOBILE_STRONGHOLD) return [];
|
|
return presentShipmentAllies(context, player, to, allies);
|
|
}
|
|
|
|
export function alliedShipmentPhysicalPool(
|
|
player: ForcePresence & { elites?: { forces: Readonly<Record<string, number>> } },
|
|
to: string,
|
|
): { normal: number; elite: number } {
|
|
let normal = 0;
|
|
let elite = 0;
|
|
for (const key in player.forces) {
|
|
if (splitLocation(key).territory !== to) continue;
|
|
const special = player.elites?.forces[key] ?? 0;
|
|
elite += special;
|
|
normal += player.forces[key] - special;
|
|
}
|
|
return { normal, elite };
|
|
}
|
|
|
|
/** Capture one physical baseline even when several allies are present. */
|
|
export function makeBasicAlliedShipment(
|
|
context: ShipmentContext & Readonly<{ turn: number }>, player: ShipmentPlayer, to: string,
|
|
): BasicAlliedShipment | null {
|
|
const witnesses = alliedShipmentWitnesses(context, player, to);
|
|
if (!witnesses.length) return null;
|
|
const allies = currentShipmentAllies(context, player);
|
|
return { player: player.id, ally: witnesses[0], turn: context.turn, territory: to,
|
|
baseline: alliedShipmentPhysicalPool(player, to),
|
|
...(allies.length > 1 ? { allies: witnesses } : {}) };
|
|
}
|
|
|
|
/** A live witness is also needed for a concealed zero-force marker. */
|
|
export function basicAlliedShipmentHasWitness(
|
|
context: ShipmentContext & Readonly<{ turn: number }>, player: ShipmentPlayer,
|
|
visit: BasicAlliedShipment | null | undefined,
|
|
): boolean {
|
|
const allies = currentShipmentAllies(context, player);
|
|
if (!visit || context.advanced || context.phase !== 5 || visit.player !== player.id ||
|
|
visit.turn !== context.turn) return false;
|
|
if (visit.allies !== undefined) {
|
|
if (context.coalitionState === undefined || !Array.isArray(visit.allies) || !visit.allies.length ||
|
|
visit.allies.some(id => typeof id !== 'string' || !id || id === player.id) ||
|
|
new Set(visit.allies).size !== visit.allies.length || visit.ally !== visit.allies[0])
|
|
throw new Error('Invalid three-member allied shipment receipt.');
|
|
}
|
|
// A legacy receipt continues to refer to its original reciprocal partner.
|
|
// For group receipts the original witnesses may leave while another current
|
|
// ally remains, so consider the complete current membership instead.
|
|
const candidates = visit.allies === undefined ? allies.filter(id => id === visit.ally) : allies;
|
|
return presentShipmentAllies(context, player, visit.territory, candidates).length > 0;
|
|
}
|
|
|
|
/** Quote physical visitors remaining alongside a current ally; territory is an ID. */
|
|
export function quoteBasicAlliedShipment(
|
|
context: ShipmentContext & Readonly<{ turn: number }>, player: ShipmentPlayer,
|
|
visit: BasicAlliedShipment | null | undefined,
|
|
): { territory: string; normal: number; elite: number } | null {
|
|
if (!visit || !basicAlliedShipmentHasWitness(context, player, visit)) return null;
|
|
const pool = alliedShipmentPhysicalPool(player, visit.territory);
|
|
const normal = Math.max(0, pool.normal - visit.baseline.normal);
|
|
const elite = Math.max(0, pool.elite - visit.baseline.elite);
|
|
return normal + elite ? { territory: visit.territory, normal, elite } : null;
|
|
}
|
|
|
|
export type AlliedSeparationQuoteContext = Readonly<{
|
|
advanced: boolean;
|
|
turn: number;
|
|
player: string;
|
|
ally: string | null;
|
|
playerAllySinceTurn?: number;
|
|
allySinceTurn?: number;
|
|
remaining: readonly string[];
|
|
territories: readonly Readonly<{
|
|
territory: string;
|
|
polar: boolean;
|
|
ownPresent: boolean;
|
|
allyPresent: boolean;
|
|
ownAdvisors: boolean;
|
|
allyAdvisors: boolean;
|
|
ecazCoexist: boolean;
|
|
}>[];
|
|
}>;
|
|
|
|
/**
|
|
* Quote the ending player's loss territories, not casualties or force custody.
|
|
* The engine supplies canonical presence, advisor stance and Ecaz coexistence.
|
|
* Advanced separates at every player's end; Basic retains its legacy timing.
|
|
*/
|
|
export function quoteAlliedSeparation(
|
|
context: AlliedSeparationQuoteContext,
|
|
): string[] {
|
|
if (!context.ally) return [];
|
|
if (
|
|
!context.advanced &&
|
|
(context.remaining.includes(context.ally) ||
|
|
(context.playerAllySinceTurn === context.turn &&
|
|
context.allySinceTurn === context.turn))
|
|
)
|
|
return [];
|
|
|
|
const territories: string[] = [];
|
|
for (const group of context.territories) {
|
|
if (
|
|
group.polar ||
|
|
!group.ownPresent ||
|
|
!group.allyPresent ||
|
|
group.ecazCoexist ||
|
|
(context.advanced && (group.ownAdvisors || group.allyAdvisors))
|
|
)
|
|
continue;
|
|
territories.push(group.territory);
|
|
}
|
|
return territories;
|
|
}
|