Connect group allied transit custody
Verify and publish checkpoint container / container (push) Successful in 10m27s

This commit is contained in:
Quintine committed 2026-10-28 22:59:38 +11:00
1 parent ea3b21f92c
commit 4798a3579b
5 files changed
+152 -23

No files matched your search

+9 -8
View File
@@ -174,14 +174,15 @@ later scoped prototype above.
Advanced ordinary physical entry is connected in the follow-up above. Advanced ordinary physical entry is connected in the follow-up above.
Refinement of interrupted and mixed-cohort interactions remains later work. Refinement of interrupted and mixed-cohort interactions remains later work.
The unexposed three-member alliance foundation can quote a receipt with The unexposed three-member alliance foundation can record a receipt with
multiple allied witnesses against one physical baseline. It checks current multiple allied witnesses against one physical baseline. The native entry,
allied occupants at turn end, including an ally who arrived after the shipment; warning and visitor-loss paths now use that group-aware quote when a group is
this online interpretation remains provisional. The native engine currently present, including a concealed zero marker. They check current allied
uses the shared receipt and quote only for the existing reciprocal pair. occupants at turn end, including an ally who arrived after the shipment; this
Group entry, turn-end handling, controls and remaining alliance effects must online interpretation remains provisional. Group formation, faction benefits,
be connected before three-member play is offered. The unlimited-size alliance funding, controls and other alliance effects must be connected before
variant requires separate work beyond the present three-member cap. 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 **Brief smoke evidence:** paid Basic allied arrival followed by Medium's
native departure and clean ending; ending without departure removes two native departure and clean ending; ending without departure removes two
+9 -4
View File
@@ -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 **Larger-alliance integration groundwork — 28 October.** Movement-end warnings
and losses for current games now use the shared group-capable separation quote. 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, The three-member option remains unexposed: consent, faction benefits, funding,
movement and battle paths are not yet integrated for groups. 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 alliance consent, movement and battle restrictions, funding, faction ally
benefits, victory and minimal legal AI. Consent, leaving behavior and shared benefits, victory and minimal legal AI. Consent, leaving behavior and shared
benefit allocation are provisional online conventions. No group option is benefit allocation are provisional online conventions. No group option is
enabled or claimed complete by the standalone foundation. The shipment helper enabled or claimed complete by the standalone foundation. Shipment entry and
now models several witnesses and one physical baseline, with the existing pair visitor consequences now use the group-aware receipt and physical baseline.
engine using the same quote. Group receipts, separation, Nexus, funding and Nexus, funding and other engine paths still need integration before the option
other engine paths still need integration before the option can be offered. can be offered.
Unconnected Nexus controls and a funding quote now model unanimous consent, Unconnected Nexus controls and a funding quote now model unanimous consent,
explicit donor allocations and edge-bound pledge authorizations. The engine explicit donor allocations and edge-bound pledge authorizations. The engine
still must issue and revoke unique edge authorizations, settle and refund still must issue and revoke unique edge authorizations, settle and refund
+13 -5
View File
@@ -101,14 +101,14 @@ export function makeBasicAlliedShipment(
...(allies.length > 1 ? { allies: witnesses } : {}) }; ...(allies.length > 1 ? { allies: witnesses } : {}) };
} }
/** Quote physical visitors remaining alongside a current ally; territory is an ID. */ /** A live witness is also needed for a concealed zero-force marker. */
export function quoteBasicAlliedShipment( export function basicAlliedShipmentHasWitness(
context: ShipmentContext & Readonly<{ turn: number }>, player: ShipmentPlayer, context: ShipmentContext & Readonly<{ turn: number }>, player: ShipmentPlayer,
visit: BasicAlliedShipment | null | undefined, visit: BasicAlliedShipment | null | undefined,
): { territory: string; normal: number; elite: number } | null { ): boolean {
const allies = currentShipmentAllies(context, player); const allies = currentShipmentAllies(context, player);
if (!visit || context.advanced || context.phase !== 5 || visit.player !== player.id || 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 (visit.allies !== undefined) {
if (context.coalitionState === undefined || !Array.isArray(visit.allies) || !visit.allies.length || if (context.coalitionState === undefined || !Array.isArray(visit.allies) || !visit.allies.length ||
visit.allies.some(id => typeof id !== 'string' || !id || id === player.id) || 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 // For group receipts the original witnesses may leave while another current
// ally remains, so consider the complete current membership instead. // ally remains, so consider the complete current membership instead.
const candidates = visit.allies === undefined ? allies.filter(id => id === visit.ally) : allies; 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 pool = alliedShipmentPhysicalPool(player, visit.territory);
const normal = Math.max(0, pool.normal - visit.baseline.normal); const normal = Math.max(0, pool.normal - visit.baseline.normal);
const elite = Math.max(0, pool.elite - visit.baseline.elite); const elite = Math.max(0, pool.elite - visit.baseline.elite);
+6 -6
View File
@@ -748,8 +748,9 @@ import {
settleAdvisors, settleAdvisors,
arrivalAsAdvisor, arrivalAsAdvisor,
} from './advisors'; } from './advisors';
import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment, import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment, basicAlliedShipmentHasWitness,
type BasicAlliedShipment } from './allied-separation'; type BasicAlliedShipment } from './allied-separation';
import type { CoalitionState } from './coalition-alliance';
import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat'; import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat';
import { FACTIONS, faction, type FactionId } from './catalog'; import { FACTIONS, faction, type FactionId } from './catalog';
import { quoteLobbyBotConfiguration } from './lobby-bot-configuration'; import { quoteLobbyBotConfiguration } from './lobby-bot-configuration';
@@ -1430,6 +1431,8 @@ type RicheseAllyOffer = {
payer: string; payer: string;
}; };
export type Game = { 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. */ /** Public written agreements. Their prose is honored by players, not parsed. */
deals?: PublicDeal[]; deals?: PublicDeal[];
/** A human-to-human lobby offer, pending the recipient's consent. */ /** A human-to-human lobby offer, pending the recipient's consent. */
@@ -19742,7 +19745,7 @@ function allowedEntry(
'That sector is in storm.', 'That sector is in storm.',
); );
const blocked = occupancyRule(() => 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.'); 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) { function basicAlliedShipmentView(g: Game, p: Player) {
const visit = g.basicAlliedShipment; const visit = g.basicAlliedShipment;
if (g.advanced || g.phase !== 5 || !visit || visit.player !== p.id || if (!visit || !occupancyRule(() => basicAlliedShipmentHasWitness(g, p, visit))) return null;
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 quote = occupancyRule(() => quoteBasicAlliedShipment(g, p, visit)); const quote = occupancyRule(() => quoteBasicAlliedShipment(g, p, visit));
const deployed = p.noField?.deployed; const deployed = p.noField?.deployed;
const noField = !!visit.noField && deployed?.tokenId === visit.noField && deployed.controller === p.id && const noField = !!visit.noField && deployed?.tokenId === visit.noField && deployed.controller === p.id &&
+115
View File
@@ -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);
});