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.
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
+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
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
+13 -5
View File
@@ -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);
+6 -6
View File
@@ -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 &&
+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);
});