From 4798a3579b4864dc424cd130d6c68a28783e8028 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:59:38 +1100 Subject: [PATCH] Connect group allied transit custody --- docs/ALLIED_TRANSIT_RULES.md | 17 ++-- docs/CURRENT_STATUS.md | 13 ++- game/allied-separation.ts | 18 ++-- game/engine.ts | 12 +-- tests/coalition-transit-engine.test.ts | 115 +++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 tests/coalition-transit-engine.test.ts diff --git a/docs/ALLIED_TRANSIT_RULES.md b/docs/ALLIED_TRANSIT_RULES.md index 1e48acfe..7a3dbd4d 100644 --- a/docs/ALLIED_TRANSIT_RULES.md +++ b/docs/ALLIED_TRANSIT_RULES.md @@ -174,14 +174,15 @@ later scoped prototype above. 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. +The unexposed three-member alliance foundation can record a receipt with +multiple allied witnesses against one physical baseline. The native entry, +warning and visitor-loss paths now use that group-aware quote when a group is +present, including a concealed zero marker. They check current allied +occupants at turn end, including an ally who arrived after the shipment; this +online interpretation remains provisional. Group formation, faction benefits, +funding, controls and other 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 diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 78b878d3..6940e823 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -28,6 +28,11 @@ the original cache to a hand; that acquisition is staged, not a played purchase. **Larger-alliance integration groundwork — 28 October.** Movement-end warnings and losses for current games now use the shared group-capable separation quote. +Territory entry and Basic shipment visitor custody now also recognize each +member of an unexposed group, including a concealed zero marker. Focused +engine cases stage the group and forces because live Nexus formation is not +yet connected; they exercise real shipment, movement and turn-end actions. The +related eight-file batch passes 53 cases, including existing pair behavior. The three-member option remains unexposed: consent, faction benefits, funding, movement and battle paths are not yet integrated for groups. @@ -119,10 +124,10 @@ 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. 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. +enabled or claimed complete by the standalone foundation. Shipment entry and +visitor consequences now use the group-aware receipt and physical baseline. +Nexus, funding and other engine paths still need integration before the option +can be offered. Unconnected Nexus controls and a funding quote now model unanimous consent, explicit donor allocations and edge-bound pledge authorizations. The engine still must issue and revoke unique edge authorizations, settle and refund diff --git a/game/allied-separation.ts b/game/allied-separation.ts index ec21371d..118e60cf 100644 --- a/game/allied-separation.ts +++ b/game/allied-separation.ts @@ -101,14 +101,14 @@ export function makeBasicAlliedShipment( ...(allies.length > 1 ? { allies: witnesses } : {}) }; } -/** Quote physical visitors remaining alongside a current ally; territory is an ID. */ -export function quoteBasicAlliedShipment( +/** 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, -): { territory: string; normal: number; elite: number } | null { +): boolean { const allies = currentShipmentAllies(context, player); if (!visit || context.advanced || context.phase !== 5 || visit.player !== player.id || - visit.turn !== context.turn) return null; + 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) || @@ -119,7 +119,15 @@ export function quoteBasicAlliedShipment( // 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; + 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); diff --git a/game/engine.ts b/game/engine.ts index 23f9973d..b3e19304 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -748,8 +748,9 @@ import { settleAdvisors, arrivalAsAdvisor, } from './advisors'; -import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment, +import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment, basicAlliedShipmentHasWitness, type BasicAlliedShipment } from './allied-separation'; +import type { CoalitionState } from './coalition-alliance'; import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat'; import { FACTIONS, faction, type FactionId } from './catalog'; import { quoteLobbyBotConfiguration } from './lobby-bot-configuration'; @@ -1430,6 +1431,8 @@ type RicheseAllyOffer = { payer: string; }; export type Game = { + /** Unexposed group-alliance integration state; absent in existing games. */ + coalitionState?: CoalitionState; /** Public written agreements. Their prose is honored by players, not parsed. */ deals?: PublicDeal[]; /** A human-to-human lobby offer, pending the recipient's consent. */ @@ -19742,7 +19745,7 @@ function allowedEntry( 'That sector is in storm.', ); const blocked = occupancyRule(() => - territoryEntryBlock(g.players, p.id, t, advisors, temporaryAlliedShipment), + territoryEntryBlock(g.players, p.id, t, advisors, temporaryAlliedShipment, g.coalitionState), ); requireRule(!blocked, blocked ?? 'This territory cannot be entered.'); } @@ -23121,10 +23124,7 @@ function alliedSeparationView(g: Game, p: Player): {territories: string[]; noFie 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; + if (!visit || !occupancyRule(() => basicAlliedShipmentHasWitness(g, p, visit))) return null; const quote = occupancyRule(() => quoteBasicAlliedShipment(g, p, visit)); const deployed = p.noField?.deployed; const noField = !!visit.noField && deployed?.tokenId === visit.noField && deployed.controller === p.id && diff --git a/tests/coalition-transit-engine.test.ts b/tests/coalition-transit-engine.test.ts new file mode 100644 index 00000000..50419d5e --- /dev/null +++ b/tests/coalition-transit-engine.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck } from '../game/cards'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { createRicheseNoField } from '../game/richese-no-field'; +import type { CoalitionState } from '../game/coalition-alliance'; + +/** Controlled Basic phase and force placement; the three-member group is + * staged because Nexus cannot create one yet. No full-game claim is made. */ +function fixture(marker = false): Game { + const game = createGame('GROUPTRANSIT', newPlayer('a', 'Visitor', marker ? 'richese' : 'atreides'), + false, marker ? ['choam'] : []); + game.players.push(newPlayer('b', 'First ally', 'emperor'), newPlayer('c', 'Second ally', 'fremen'), + newPlayer('d', 'Outsider', 'harkonnen')); + game.status = 'playing'; game.phase = 5; game.turn = 2; game.active = 'a'; game.storm = 18; + game.order = ['a', 'b', 'c', 'd']; game.movementRemaining = ['a', 'b', 'c', 'd']; + game.deck = baseDeck(); + for (const player of game.players) { + player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10; + player.hand = []; player.traitorChoices = []; player.traitors = []; + player.ally = null; delete player.allySinceTurn; + player.shipped = false; player.moved = 0; + } + game.players[0].forces['imperial_basin:11'] = 2; game.players[0].reserves = 18; + game.players[1].forces['imperial_basin:11'] = 2; game.players[1].reserves = 18; + game.players[2].forces['polar_sink:0'] = 2; game.players[2].reserves = 18; + 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: 2 }, + { members: ['b', 'c'], formedTurn: 2 }, + ] }] }; + game.coalitionState = coalitionState; + if (marker) { + game.players[0].noField = createRicheseNoField(['zero', 'three', 'five']); + game.players[0].noFieldEvent = 'marker-event'; + } + return game; +} + +const send = (game: Game, id: string, action: Action) => applyAction(game, id, action); +function finishResponse(game: Game) { + for (let count = 0; game.response && count < 10; count++) { + const next = game.players.find(player => !game.response!.passed.includes(player.id)); + assert.ok(next); + game = send(game, next.id, {type: 'passResponse'}); + } + assert.equal(game.pendingShipment ?? null, null); + return game; +} +function ship(game: Game): Game { + return finishResponse(send(game, 'a', {type: 'ship', amount: 3, elite: 0, + territory: 'imperial_basin', sector: 9, allyPayment: 0})); +} +function warn(game: Game) { return viewGame(game, 'a').basicAlliedShipment; } +function forceTotal(game: Game, id: string, prefix: string) { + return Object.entries(game.players.find(player => player.id === id)!.forces) + .reduce((total, [key, value]) => total + (key.startsWith(`${prefix}:`) ? value : 0), 0); +} + +void test('ordinary movement cannot enter either group ally territory', () => { + const game = fixture(); + // Put the second ally in a non-polar site without changing its physical pool. + delete game.players[2].forces['polar_sink:0']; + game.players[2].forces['red_chasm:7'] = 2; + const before = structuredClone(game); + assert.throws(() => send(game, 'a', {type: 'move', forces: {'imperial_basin:11': 1}, + territory: 'red_chasm', sector: 7}), /occupied by your ally/); + assert.deepEqual(game, before); +}); + +void test('a three-member group visitor can ship then depart without losing its pre-arrival forces', () => { + const original = fixture(); let game = ship(original); + assert.equal(game.players[0].spice, 4); + assert.deepEqual(game.basicAlliedShipment?.allies, ['b']); + assert.equal(warn(game)?.normal, 3); + game = send(game, 'a', {type: 'move', forces: {'imperial_basin:9': 3}, + territory: 'polar_sink', sector: 0}); + assert.equal(warn(game), null); + game = send(game, 'a', {type: 'endMovement'}); + assert.equal(game.players[0].tanks, 0); + assert.equal(forceTotal(game, 'a', 'imperial_basin'), 2); + assert.equal(forceTotal(game, 'a', 'polar_sink'), 3); + assert.equal(forceTotal(game, 'b', 'imperial_basin'), 2); +}); + +void test('group visitor warning and loss follow a different current ally with partial departure', () => { + let game = ship(fixture()); + // Staged witness switch: relocate existing counters only, preserving each + // physical pool; gameplay does not move another ally during this turn. + delete game.players[1].forces['imperial_basin:11']; game.players[1].forces['polar_sink:0'] = 2; + delete game.players[2].forces['polar_sink:0']; game.players[2].forces['imperial_basin:11'] = 2; + assert.equal(warn(game)?.normal, 3); + game = send(game, 'a', {type: 'move', forces: {'imperial_basin:9': 2}, + territory: 'polar_sink', sector: 0}); + assert.equal(warn(game)?.normal, 1); + game = send(game, 'a', {type: 'endMovement'}); + assert.equal(game.players[0].tanks, 1); + assert.equal(forceTotal(game, 'a', 'imperial_basin'), 2); + assert.equal(forceTotal(game, 'c', 'imperial_basin'), 2); + assert.equal(forceTotal(game, 'b', 'polar_sink'), 2); +}); + +void test('group visiting zero No-Field remains visible as a marker obligation and reveals without loss', () => { + let game = fixture(true); + game = finishResponse(send(game, 'a', {type: 'ship', noField: 'zero', event: 'marker-event', + territory: 'imperial_basin', sector: 9, allyPayment: 0})); + assert.deepEqual(warn(game), {territory: 'Imperial Basin', normal: 0, elite: 0, noField: true}); + assert.equal(viewGame(game, 'b').basicAlliedShipment, null); + game = send(game, 'a', {type: 'endMovement'}); + assert.equal(game.players[0].noField!.deployed, null); + assert.equal(game.players[0].noField!.lastShipped, 'zero'); + assert.equal(game.players[0].tanks, 0); + assert.equal(forceTotal(game, 'a', 'imperial_basin'), 2); + assert.equal(forceTotal(game, 'b', 'imperial_basin'), 2); +});