Prepare allied shipment quotes for larger alliances
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
cea310b030
commit
77376c39d6
5 files changed
+210
-28
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+89
-12
@@ -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<Record<string, number>> };
|
||||
}>;
|
||||
|
||||
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;
|
||||
|
||||
+8
-13
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user