diff --git a/docs/ALLIED_TRANSIT_RULES.md b/docs/ALLIED_TRANSIT_RULES.md index f9bec3df..df9cf3cd 100644 --- a/docs/ALLIED_TRANSIT_RULES.md +++ b/docs/ALLIED_TRANSIT_RULES.md @@ -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; diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 419b75d6..c86c600c 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/game/allied-separation.ts b/game/allied-separation.ts index b9a6106d..b64b80d2 100644 --- a/game/allied-separation.ts +++ b/game/allied-separation.ts @@ -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> }; +}>; + +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; diff --git a/game/engine.ts b/game/engine.ts index f67fa39c..591ab218 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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, diff --git a/tests/coalition-allied-shipment.test.ts b/tests/coalition-allied-shipment.test.ts new file mode 100644 index 00000000..bc4de9aa --- /dev/null +++ b/tests/coalition-allied-shipment.test.ts @@ -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/); +});