Prepare allied shipment quotes for larger alliances
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-28 22:01:51 +11:00
1 parent cea310b030
commit 77376c39d6
5 files changed
+210 -28

No files matched your search

+89 -12
View File
@@ -2,34 +2,74 @@ 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';
/** 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 }>;
}>;
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.advanced || context.players.some(seat => seat.faction === 'ecaz')))
throw new Error('Three-member shipment rules require Basic play 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: Readonly<{
phase: number;
active: string | null;
players: readonly OccupancySeat[];
}>,
player: OccupancySeat & Readonly<{ shipped?: boolean; moved?: number }>,
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 false;
const ally = context.players.find(seat => seat.id === player.ally);
return !!ally && ally.ally === player.id && presenceAt(ally, to) > 0 &&
ecazOccupancyRelation(context.players, player.id, ally.id, {
kind: 'territory', id: to,
}) !== 'ecazAlliance';
to === MOBILE_STRONGHOLD) return [];
return presentShipmentAllies(context, player, to, allies);
}
export function alliedShipmentPhysicalPool(
@@ -47,6 +87,43 @@ export function alliedShipmentPhysicalPool(
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 } : {}) };
}
/** 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 {
const allies = currentShipmentAllies(context, player);
if (!visit || context.advanced || context.phase !== 5 || visit.player !== player.id ||
visit.turn !== context.turn) return null;
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;
if (!presentShipmentAllies(context, player, visit.territory, candidates).length) 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;
+8 -13
View File
@@ -747,7 +747,7 @@ import {
settleAdvisors,
arrivalAsAdvisor,
} from './advisors';
import { quoteAlliedSeparation, isAlliedShipmentVisit, alliedShipmentPhysicalPool,
import { quoteAlliedSeparation, isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment,
type BasicAlliedShipment } from './allied-separation';
import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat';
import { FACTIONS, faction, type FactionId } from './catalog';
@@ -23086,13 +23086,9 @@ function basicAlliedShipmentView(g: Game, p: Player) {
const visit = g.basicAlliedShipment;
if (g.advanced || g.phase !== 5 || !visit || visit.player !== p.id ||
visit.turn !== g.turn || p.ally !== visit.ally) return null;
const ally = getPlayer(g, visit.ally);
if (ally.ally !== p.id || !at(ally, visit.territory) ||
sharesEcazOccupation(g, p, visit.territory)) return null;
const pool = alliedShipmentPhysicalPool(p, 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: territory(visit.territory).name, normal, elite } : null;
getPlayer(g, visit.ally);
const quote = occupancyRule(() => quoteBasicAlliedShipment(g, p, visit));
return quote ? { ...quote, territory: territory(quote.territory).name } : null;
}
/** Provisional no-exit policy: only the excess physical visitors go to Tanks. */
@@ -38445,11 +38441,10 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
}
let homeworldOrigins = '';
if (!shipment.noField) {
if (!g.advanced && !shipment.alliedNoField && n > 0 && isAlliedShipmentVisit(g, p, to))
g.basicAlliedShipment = {
player: p.id, ally: p.ally!, turn: g.turn, territory: to,
baseline: alliedShipmentPhysicalPool(p, to),
};
if (!g.advanced && !shipment.alliedNoField && n > 0) {
const visit = occupancyRule(() => makeBasicAlliedShipment(g, p, to));
if (visit) g.basicAlliedShipment = visit;
}
const receipts = withdrawNativeReserves(
g,
p,