Connect group allied transit custody
Verify and publish checkpoint container / container (push) Successful in 10m27s
Verify and publish checkpoint container / container (push) Successful in 10m27s
This commit is contained in:
1 parent
ea3b21f92c
commit
4798a3579b
5 files changed
+152
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user