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
+209 -27

No files matched your search

+9
View File
@@ -136,6 +136,15 @@ optional arrival compositions are not expanded by the Basic prototype.
Advanced ordinary physical entry is connected in the follow-up above.
Refinement of interrupted and mixed-cohort interactions remains later work.
The unexposed three-member alliance foundation can quote a receipt with
multiple allied witnesses against one physical baseline. It checks current
allied occupants at turn end, including an ally who arrived after the shipment;
this online interpretation remains provisional. The native engine currently
uses the shared receipt and quote only for the existing reciprocal pair.
Group entry, turn-end handling, controls and remaining alliance effects must
be connected before three-member play is offered. The unlimited-size alliance
variant requires separate work beyond the present three-member cap.
**Brief smoke evidence:** paid Basic allied arrival followed by Medium's
native departure and clean ending; ending without departure removes two
new visitors while preserving five old counters and the ally's three;
+7 -3
View File
@@ -25,9 +25,13 @@ A usable preview must connect every group member to native
alliance consent, movement and battle restrictions, funding, faction ally
benefits, victory and minimal legal AI. Consent, leaving behavior and shared
benefit allocation are provisional online conventions. No group option is
enabled or claimed complete by the standalone foundation. Allied shipment
receipts and separation still assume a single ally, as do other engine paths.
The combined related batch passes 189 cases, with types and lint passing.
enabled or claimed complete by the standalone foundation. The shipment helper
now models several witnesses and one physical baseline, with the existing pair
engine using the same quote. Group receipts, separation, Nexus, funding and
other engine paths still need integration before the option can be offered.
The separate printed unlimited-size / three-stronghold variant remains missing;
the current group model deliberately caps membership at three. The combined
earlier batch passed 189 cases; the shipment batch passed 58 cases.
**Conditional future-battle Truthtrance in Basic Charity — 28 October.
Prototyped / Partial.** The named-leader question also works in a quiet
+88 -11
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 }>;
}>;
/** Ordinary reserve arrivals only; movement endpoints keep their guard. */
export function isAlliedShipmentVisit(
context: Readonly<{
type ShipmentContext = Readonly<{
phase: number;
active: string | null;
players: readonly OccupancySeat[];
}>,
player: OccupancySeat & Readonly<{ shipped?: boolean; moved?: number }>,
to: string,
): boolean {
if (context.phase !== 5 || context.active !== player.id ||
player.shipped !== false || player.moved !== 0 || to === 'polar_sink' ||
to === MOBILE_STRONGHOLD) return false;
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.ally === player.id && presenceAt(ally, to) > 0 &&
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: 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(
@@ -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,
+97
View File
@@ -0,0 +1,97 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer } from '../game/engine';
import { alliedShipmentWitnesses, isAlliedShipmentVisit, makeBasicAlliedShipment,
quoteBasicAlliedShipment } from '../game/allied-separation';
import type { CoalitionState } from '../game/coalition-alliance';
function fixture() {
const g = createGame('GROUPSHIP', newPlayer('a', 'Atreides', 'atreides'), false);
g.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
newPlayer('d', 'Harkonnen', 'harkonnen'));
Object.assign(g, { advanced: false, phase: 5, turn: 4, active: 'a' });
for (const p of g.players) { p.forces = {}; p.ally = null; }
Object.assign(g.players[0], { shipped: false, moved: 0 });
const coalitionState: CoalitionState = { version: 1, revision: 1, proposal: null, usedEvents: [],
groups: [{ id: 'abc', members: ['a', 'b', 'c'], formedTurn: 2, edges: [
{ members: ['a', 'b'], formedTurn: 2 }, { members: ['a', 'c'], formedTurn: 4 },
{ members: ['b', 'c'], formedTurn: 4 },
] }] };
return { g, coalitionState, p: g.players[0] };
}
void test('group arrival records every present ally and a single physical baseline', () => {
const { g, coalitionState, p } = fixture();
p.forces = { 'red_chasm:7': 3 };
g.players[1].forces = { 'red_chasm:7': 1 };
g.players[2].forces = { 'red_chasm:7': 1 };
const context = { ...g, coalitionState };
const before = structuredClone(context);
assert.deepEqual(alliedShipmentWitnesses(context, p, 'red_chasm'), ['b', 'c']);
const receipt = makeBasicAlliedShipment(context, p, 'red_chasm')!;
assert.deepEqual(receipt, { player: 'a', ally: 'b', allies: ['b', 'c'], turn: 4,
territory: 'red_chasm', baseline: { normal: 3, elite: 0 } });
assert.deepEqual(context, before);
p.forces['red_chasm:7'] += 2;
assert.deepEqual(quoteBasicAlliedShipment(context, p, receipt), { territory: 'red_chasm', normal: 2, elite: 0 });
g.players[1].forces = {};
assert.equal(quoteBasicAlliedShipment(context, p, receipt)?.normal, 2);
g.players[2].forces = {};
assert.equal(quoteBasicAlliedShipment(context, p, receipt), null);
});
void test('group quote follows a current ally who was not present at shipment', () => {
const { g, coalitionState, p } = fixture();
g.players[1].forces = { 'red_chasm:7': 1 };
const context = { ...g, coalitionState };
const receipt = makeBasicAlliedShipment(context, p, 'red_chasm')!;
assert.deepEqual(receipt.allies, ['b']);
p.forces = { 'red_chasm:7': 2 };
g.players[1].forces = {};
g.players[2].forces = { 'red_chasm:7': 1 };
assert.equal(quoteBasicAlliedShipment(context, p, receipt)?.normal, 2);
p.forces = {};
assert.equal(quoteBasicAlliedShipment(context, p, receipt), null);
});
void test('group quote separately retains the normal and elite physical baseline', () => {
const { g, coalitionState, p } = fixture();
p.forces = { 'red_chasm:7': 3 };
p.elites = { reserves: 4, tanks: 0, revived: 0, forces: { 'red_chasm:7': 1 } };
g.players[1].forces = { 'red_chasm:7': 1 };
const context = { ...g, coalitionState };
const receipt = makeBasicAlliedShipment(context, p, 'red_chasm')!;
assert.deepEqual(receipt.baseline, { normal: 2, elite: 1 });
p.forces['red_chasm:7'] = 6;
p.elites.forces['red_chasm:7'] = 2;
assert.deepEqual(quoteBasicAlliedShipment(context, p, receipt), { territory: 'red_chasm', normal: 2, elite: 1 });
});
void test('legacy pair receipt stays compatible and requires its original reciprocal ally', () => {
const { g, p } = fixture();
p.ally = 'b'; g.players[1].ally = 'a';
p.forces = { 'red_chasm:7': 2 }; g.players[1].forces = { 'red_chasm:7': 1 };
const receipt = makeBasicAlliedShipment(g, p, 'red_chasm')!;
assert.equal(receipt.allies, undefined);
assert.deepEqual(JSON.parse(JSON.stringify(receipt)), receipt);
p.forces['red_chasm:7'] += 3;
assert.equal(quoteBasicAlliedShipment(g, p, receipt)?.normal, 3);
g.players[1].ally = null;
assert.equal(quoteBasicAlliedShipment(g, p, receipt), null);
});
void test('shipment eligibility keeps turn and destination guards and rejects invalid ledgers', () => {
const { g, coalitionState, p } = fixture();
g.players[2].forces = { 'red_chasm:7': 1, 'polar_sink:0': 1 };
const context = { ...g, coalitionState };
assert.equal(isAlliedShipmentVisit(context, p, 'red_chasm'), true);
assert.equal(isAlliedShipmentVisit(context, p, 'polar_sink'), false);
assert.equal(isAlliedShipmentVisit({ ...context, active: 'b' }, p, 'red_chasm'), false);
p.moved = 1;
assert.equal(isAlliedShipmentVisit(context, p, 'red_chasm'), false);
assert.throws(() => isAlliedShipmentVisit({ ...context, coalitionState: null as unknown as CoalitionState }, p, 'red_chasm'), /ledger cannot be null/);
p.moved = 0;
assert.throws(() => isAlliedShipmentVisit({ ...context, advanced: true }, p, 'red_chasm'), /Basic play without Ecaz/);
g.players[3].faction = 'ecaz';
assert.throws(() => isAlliedShipmentVisit(context, p, 'red_chasm'), /Basic play without Ecaz/);
});