Files
Dune/tests/bg-ambassador-arrival.test.ts
T
Quintine 767bace774 Separate Spiritual Advisor shipment matching and alone conversion
Apply the Advanced retained-fighter target cutover through shared quotes and controls. Preserve committed foreign shipments while handling free-packet shipping, Polar matching, actual placement and original arrival once.
2026-10-24 22:28:42 +11:00

326 lines
18 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
applyBgAmbassadorArrivalStep, beginBgAmbassadorArrival,
bgAmbassadorAccompanimentAction, bgAmbassadorArrivalPlayer,
bgAmbassadorIntrusionAction, bgAmbassadorOfferAction,
createBgAmbassadorArrivalFixture, finishBgAmbassadorArrival,
recordControlledLegacyBgAdvisorResponse, settleBgAmbassadorArrivalControls,
type BgAmbassadorArrivalFixture,
} from './fixture-bg-ambassador-arrival';
function assertStock(game: Game) {
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20);
if (player.elites) {
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0), 5);
for (const [key, count] of Object.entries(player.elites.forces)) assert.ok(count <= (player.forces[key] ?? 0));
}
}
}
function assertPaidOnce(fixture: BgAmbassadorArrivalFixture, game: Game) {
const before = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.entrant);
const entrant = bgAmbassadorArrivalPlayer(game, fixture.entrant);
assert.equal(entrant.spice, before.spice - 1);
assert.equal(entrant.reserves, before.reserves - 1);
assert.equal(entrant.forces[fixture.location], 1);
assert.equal(entrant.shipped, true);
assert.equal(entrant.moved, before.moved);
assert.deepEqual(entrant.elites, before.elites);
assert.equal(game.active, fixture.entrant);
assert.equal(game.turn, fixture.beforeShipment.turn);
assert.equal(game.phase, fixture.beforeShipment.phase);
assertStock(game);
}
function assertMainQueue(fixture: BgAmbassadorArrivalFixture, game: Game) {
assert.deepEqual(game.pendingArrivalReaction, [{
entrant: fixture.entrant, territory: fixture.territory, sector: 11,
amount: 1, elite: 0, cause: 'shipment', resume: 'none',
turn: fixture.beforeShipment.turn, phase: fixture.beforeShipment.phase,
}]);
assert.equal(game.pendingAmbassador ?? null, null);
assertPaidOnce(fixture, game);
}
function assertMainOffer(fixture: BgAmbassadorArrivalFixture, game: Game) {
const entry = game.pendingAmbassador;
assert.ok(entry);
assert.equal(game.decision?.kind, 'ecazAmbassador');
assert.equal(game.decision?.player, fixture.owner);
assert.equal(entry.owner, fixture.owner);
assert.equal(entry.entrant, fixture.entrant, 'The BG advisor arrival must not replace the committed main entrant');
assert.equal(entry.token, fixture.token);
assert.equal(entry.territory, fixture.territory);
assert.equal(entry.sector, 11);
assert.equal(entry.stage, 'offer');
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
assertPaidOnce(fixture, game);
}
function assertOriginalMovementResumes(fixture: BgAmbassadorArrivalFixture, game: Game, trigger: boolean) {
assert.equal(game.decision ?? null, null);
assert.equal(game.response ?? null, null);
assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
assertPaidOnce(fixture, game);
const token = game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)!;
assert.equal(token.zone, trigger ? 'supply' : 'placed');
assert.equal(token.location, trigger ? null : fixture.territory);
if (trigger) {
assert.equal(game.dukeVidal?.controller, fixture.owner);
assert.equal(game.dukeVidal?.leader.strength, 6);
} else assert.equal(game.dukeVidal?.controller ?? null, null);
const before = bgAmbassadorArrivalPlayer(game, fixture.entrant);
const bgBefore = bgAmbassadorArrivalPlayer(game, fixture.advisor);
let moved = applyBgAmbassadorArrivalStep(game, { actor: fixture.entrant, action: fixture.movementAction });
if (moved.response?.automaticAdvisorFlip) {
assert.equal(moved.response.automaticAdvisorFlip.producer, 'groundMovement');
assert.equal(moved.response.owner, fixture.advisor);
assert.equal(moved.response.location, fixture.territory);
moved = settleBgAmbassadorArrivalControls(moved);
}
const entrant = bgAmbassadorArrivalPlayer(moved, fixture.entrant);
assert.equal(entrant.forces[fixture.location] ?? 0, 0);
assert.equal(entrant.forces['arsunt:11'], 1);
assert.equal(entrant.spice, before.spice);
assert.equal(entrant.reserves, before.reserves);
assert.equal(entrant.moved, before.moved + 1);
assert.equal(entrant.shipped, true);
assert.equal(moved.active, fixture.entrant);
assert.equal(moved.phase, game.phase);
assert.equal(moved.turn, game.turn);
assert.equal(moved.pendingAmbassador ?? null, null);
assert.equal(moved.pendingArrivalReaction?.length ?? 0, 0);
assert.deepEqual(bgAmbassadorArrivalPlayer(moved, fixture.advisor).forces, bgBefore.forces);
assert.equal(bgAmbassadorArrivalPlayer(moved, fixture.advisor).reserves, bgBefore.reserves);
// Allow any actual alone-conversion counter after the original entrant moves
// out, without another entry event or another Ambassador prompt.
if (bgBefore.forces[fixture.location])
assert.equal(bgAmbassadorArrivalPlayer(moved, fixture.advisor).advisors?.[fixture.territory], undefined);
assertStock(moved);
}
for (const accept of [true, false]) {
void test(`original paid stronghold entry waits for BG's own ${accept ? 'accepted' : 'declined'} advisor before one Ecaz offer`, () => {
const fixture = createBgAmbassadorArrivalFixture();
assertStock(fixture.afterSetup);
assertStock(fixture.beforeShipment);
const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor);
const cohort = structuredClone(fixture.beforeShipment.ecazAmbassadors!.cohort);
let game = beginBgAmbassadorArrival(fixture);
assert.equal(game.decision?.kind, 'advisor');
assert.equal(game.decision?.player, fixture.advisor);
assert.equal(viewGame(game, fixture.advisor).decision?.player, fixture.advisor);
assertMainQueue(fixture, game);
assert.throws(() => applyAction(game, fixture.entrant, { type: 'endMovement' }));
assert.throws(() => applyAction(game, fixture.owner, bgAmbassadorAccompanimentAction(accept)));
game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(accept) });
if (accept) {
assert.equal(game.response?.kind, 'advisor', 'The actual original Karama holder keeps the native cancellation response open');
assertMainQueue(fixture, game);
}
game = settleBgAmbassadorArrivalControls(game);
const bg = bgAmbassadorArrivalPlayer(game, fixture.advisor);
assert.equal(bg.reserves, bgBefore.reserves - (accept ? 1 : 0));
assert.equal(bg.forces[fixture.location] ?? 0, accept ? 1 : 0);
assert.equal(bg.spice, bgBefore.spice);
assert.equal(bg.forces['imperial_basin:10'], 1);
if (accept) assert.equal(bg.advisors?.[fixture.territory]?.lockedTurn, game.turn);
else assert.equal(bg.advisors?.[fixture.territory], undefined);
assertMainOffer(fixture, game);
const spentOffer = bgAmbassadorOfferAction(game, accept);
game = finishBgAmbassadorArrival(game, accept);
assert.deepEqual(game.ecazAmbassadors!.cohort, cohort);
assert.throws(() => applyAction(game, fixture.owner, spentOffer), 'The single original offer cannot be replayed');
assertOriginalMovementResumes(fixture, game, accept);
});
}
for (const becomeAdvisors of [true, false]) {
void test(`original moved BG fighter ${becomeAdvisors ? 'accepts' : 'declines'} Intrusion before accompaniment and the main Ambassador entry`, () => {
const fixture = createBgAmbassadorArrivalFixture({ fighters: true });
assert.equal(fixture.afterSetup.turn, 1);
assert.equal(fixture.beforeShipment.turn, 2);
assert.ok(fixture.actions.some(step => step.actor === fixture.advisor && step.action.type === 'move' && step.action.from === 'imperial_basin:10'));
const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor);
assert.equal(bgBefore.forces[fixture.location], 1);
assert.equal(bgBefore.forces['imperial_basin:10'] ?? 0, 0);
assert.equal(bgBefore.advisors?.[fixture.territory], undefined);
let game = beginBgAmbassadorArrival(fixture);
assert.equal(game.decision?.kind, 'intrusion');
assert.equal(game.decision?.player, fixture.advisor);
assertMainQueue(fixture, game);
assert.throws(() => applyAction(game, fixture.entrant, bgAmbassadorIntrusionAction(becomeAdvisors)));
game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorIntrusionAction(becomeAdvisors) });
if (becomeAdvisors) {
assert.equal(game.response?.kind, 'advisorFlip');
assert.equal(game.response?.owner, fixture.advisor);
assertMainQueue(fixture, game);
}
game = settleBgAmbassadorArrivalControls(game);
assert.equal(game.decision?.kind, 'advisor');
assert.equal(game.decision?.player, fixture.advisor);
assert.equal(!!bgAmbassadorArrivalPlayer(game, fixture.advisor).advisors?.[fixture.territory], becomeAdvisors);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).reserves, bgBefore.reserves);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces[fixture.location], 1);
assertMainQueue(fixture, game);
// Fresh Advanced accompaniment cannot join retained fighters. Declining the
// reserve transfer preserves them; accepted Intrusion permits the new advisor.
if (!becomeAdvisors) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, fixture.advisor, bgAmbassadorAccompanimentAction(true)),
/cannot accompany/);
assert.deepEqual(game, before);
}
game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(becomeAdvisors) });
if (becomeAdvisors) {
assert.equal(game.response?.kind, 'advisor');
assertMainQueue(fixture, game);
}
game = settleBgAmbassadorArrivalControls(game);
const bg = bgAmbassadorArrivalPlayer(game, fixture.advisor);
assert.equal(bg.forces[fixture.location], becomeAdvisors ? 2 : 1);
assert.equal(bg.reserves, bgBefore.reserves - (becomeAdvisors ? 1 : 0));
assert.equal(bg.spice, bgBefore.spice);
if (becomeAdvisors) assert.equal(bg.advisors?.[fixture.territory]?.lockedTurn, game.turn);
else assert.equal(bg.advisors?.[fixture.territory], undefined);
assertMainOffer(fixture, game);
game = finishBgAmbassadorArrival(game, !becomeAdvisors);
assertOriginalMovementResumes(fixture, game, !becomeAdvisors);
});
}
void test('fresh retained fighters reject destination accompaniment while Polar Sink preserves the original paid entry', () => {
const fixture = createBgAmbassadorArrivalFixture({ fighters: true });
const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor);
let game = beginBgAmbassadorArrival(fixture);
game = applyBgAmbassadorArrivalStep(game, {
actor: fixture.advisor, action: bgAmbassadorIntrusionAction(false),
});
const before = structuredClone(game);
assert.throws(() => applyAction(game, fixture.advisor, bgAmbassadorAccompanimentAction(true)),
/cannot accompany/);
assert.deepEqual(game, before);
game = applyBgAmbassadorArrivalStep(game, {
actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(true, false),
});
assert.equal(game.response?.kind, 'advisor');
assert.ok(game.response?.spiritualAdvisorSource);
assertMainQueue(fixture, game);
game = settleBgAmbassadorArrivalControls(game);
const bg = bgAmbassadorArrivalPlayer(game, fixture.advisor);
assert.equal(bg.forces[fixture.location], 1);
assert.equal(bg.advisors?.[fixture.territory], undefined);
assert.equal(bg.forces['polar_sink:0'], 1);
assert.equal(bg.advisors?.polar_sink, undefined);
assert.equal(bg.reserves, bgBefore.reserves - 1);
assertMainOffer(fixture, game);
assertOriginalMovementResumes(fixture, finishBgAmbassadorArrival(game, false), false);
});
void test('controlled recorded legacy accompaniment preserves fighters: one BG child offer then one original shipper offer', () => {
const fixture = createBgAmbassadorArrivalFixture({ fighters: true });
const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor);
let game = beginBgAmbassadorArrival(fixture);
assert.equal(game.decision?.kind, 'intrusion');
assertMainQueue(fixture, game);
const mainQueue = structuredClone(game.pendingArrivalReaction);
game = applyBgAmbassadorArrivalStep(game, {
actor: fixture.advisor, action: bgAmbassadorIntrusionAction(false),
});
assert.equal(game.decision?.kind, 'advisor');
assertMainQueue(fixture, game);
game = recordControlledLegacyBgAdvisorResponse(game);
assert.equal(game.response?.kind, 'advisor');
assertMainQueue(fixture, game);
game = settleBgAmbassadorArrivalControls(game);
const bg = bgAmbassadorArrivalPlayer(game, fixture.advisor);
assert.equal(bg.forces[fixture.location], 2);
assert.equal(bg.reserves, bgBefore.reserves - 1);
assert.equal(bg.spice, bgBefore.spice);
assert.equal(bg.advisors?.[fixture.territory], undefined, 'Accompaniment must match the existing fighter group');
const child = game.pendingAmbassador;
assert.ok(child);
assert.equal(game.decision?.kind, 'ecazAmbassador');
assert.equal(game.decision?.player, fixture.owner);
assert.equal(child.entrant, fixture.advisor, 'This physical BG fighter arrival is independently eligible');
assert.equal(child.owner, fixture.owner);
assert.equal(child.token, fixture.token);
assert.equal(child.territory, fixture.territory);
assert.deepEqual(game.pendingArrivalReaction, mainQueue, 'The original paid arrival waits while the eligible BG child resolves');
assertPaidOnce(fixture, game);
const childEvent = child.event;
// Existing callback order is child-first, not an invented FIFO priority.
// Keeping the token in place lets the queued original entry open its own
// single offer without pretending the fighter child was an advisor.
game = finishBgAmbassadorArrival(game, false);
assertMainOffer(fixture, game);
assert.notEqual(game.pendingAmbassador!.event, childEvent);
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)!.zone, 'placed');
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces[fixture.location], 2);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).reserves, bgBefore.reserves - 1);
game = finishBgAmbassadorArrival(game, true);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).advisors?.[fixture.territory], undefined);
assertOriginalMovementResumes(fixture, game, true);
});
void test('controlled recorded legacy fighter child returns the live Ecaz token before the queued shipper entry resumes', () => {
const fixture = createBgAmbassadorArrivalFixture({ fighters: true });
const bgBefore = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor);
let game = beginBgAmbassadorArrival(fixture);
game = applyBgAmbassadorArrivalStep(game, {
actor: fixture.advisor, action: bgAmbassadorIntrusionAction(false),
});
assert.equal(game.decision?.kind, 'advisor');
game = settleBgAmbassadorArrivalControls(recordControlledLegacyBgAdvisorResponse(game));
assert.equal(game.pendingAmbassador?.entrant, fixture.advisor);
assert.equal(game.pendingArrivalReaction?.[0]?.entrant, fixture.entrant);
const spentChild = bgAmbassadorOfferAction(game, true);
// This pins the existing provisional child-first/live-token composition,
// not a publisher priority: triggering returns the reusable token to supply,
// so processing the original committed entry cannot trigger that token again.
game = finishBgAmbassadorArrival(game, true);
assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.pendingArrivalReaction?.length ?? 0, 0);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces[fixture.location], 2);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).reserves, bgBefore.reserves - 1);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).advisors?.[fixture.territory], undefined);
assert.throws(() => applyAction(game, fixture.owner, spentChild));
assertOriginalMovementResumes(fixture, game, true);
});
void test('canceling the real accepted accompaniment preserves the original paid Ambassador entry without a reserve transfer', () => {
const fixture = createBgAmbassadorArrivalFixture();
const before = bgAmbassadorArrivalPlayer(fixture.beforeShipment, fixture.advisor);
let game = beginBgAmbassadorArrival(fixture);
game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(true) });
const card = viewGame(game, fixture.owner).responseControls?.cancelCards[0];
assert.ok(card);
game = applyBgAmbassadorArrivalStep(game, { actor: fixture.owner, action: { type: 'card', mode: 'cancel', card } });
assertMainOffer(fixture, game);
assert.deepEqual(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces, before.forces);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).reserves, before.reserves);
assert.equal(game.discard.filter(c => c.id === card).length, 1);
assertOriginalMovementResumes(fixture, finishBgAmbassadorArrival(game, false), false);
});
void test('canceling the real Intrusion flip retains fighters and the followup before the original Ambassador entry', () => {
const fixture = createBgAmbassadorArrivalFixture({ fighters: true });
let game = beginBgAmbassadorArrival(fixture);
game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorIntrusionAction(true) });
const card = viewGame(game, fixture.owner).responseControls?.cancelCards[0];
assert.ok(card);
game = applyBgAmbassadorArrivalStep(game, { actor: fixture.owner, action: { type: 'card', mode: 'cancel', card } });
assert.equal(game.decision?.kind, 'advisor');
assertMainQueue(fixture, game);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).advisors?.[fixture.territory], undefined);
assert.equal(bgAmbassadorArrivalPlayer(game, fixture.advisor).forces[fixture.location], 1);
const retained = structuredClone(game);
assert.throws(() => applyAction(game, fixture.advisor, bgAmbassadorAccompanimentAction(true)),
/cannot accompany/);
assert.deepEqual(game, retained);
game = applyBgAmbassadorArrivalStep(game, { actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(false) });
assertMainOffer(fixture, game);
assert.equal(game.discard.filter(c => c.id === card).length, 1);
assertOriginalMovementResumes(fixture, finishBgAmbassadorArrival(game, false), false);
});