Connect native Guild and BG own-arrival continuations

This commit is contained in:
Quintine committed 2026-10-15 19:53:15 +11:00
1 parent e265af3cb3
commit c10fe0b48c
19 files changed
+1051 -268

No files matched your search

+285
View File
@@ -0,0 +1,285 @@
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,
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);
const moved = applyBgAmbassadorArrivalStep(game, { actor: fixture.entrant, action: fixture.movementAction });
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);
// If BG remains alone after the original entrant moves out, its stance changes
// automatically 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);
// Keeping fighters and accepting accompaniment would join that fighter
// group, not manufacture an advisor. Here that branch legitimately declines
// the reserve transfer; the accepted branch joins the newly peaceful group.
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('declining Intrusion and accepting 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 = applyBgAmbassadorArrivalStep(game, {
actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(true),
});
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('triggering the genuine BG 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(applyBgAmbassadorArrivalStep(game, {
actor: fixture.advisor, action: bgAmbassadorAccompanimentAction(true),
}));
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);
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);
});
-40
View File
@@ -12,10 +12,8 @@ import {
movementArrivalGame,
} from './deferred-movement-arrival-fixture';
import {
addShipmentArrivalAmbassador,
shipmentArrivalAction,
shipmentArrivalGame,
richeseNoFieldArrivalAction,
} from './fixture-deferred-shipment-arrival';
const profiles = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
@@ -134,44 +132,6 @@ void test('single reactions and matching-faction Ambassador immunity remain play
}
});
void test('shipment detects prospective BG accompaniment including a concealed No-Field without inspecting its denomination', () => {
const game = shipmentArrivalGame();
addShipmentArrivalAmbassador(game);
assert.throws(() => applyAction(game, game.active!, shipmentArrivalAction));
game.active = 'richese';
arrivalPlayer(game, 'richese').shipped = false;
const marker = richeseNoFieldArrivalAction(game);
assert.throws(() => applyAction(game, 'richese', marker));
});
void test('Advanced BG fighter intrusion is distinguished from advisor presence', () => {
const game = shipmentArrivalGame();
addShipmentArrivalAmbassador(game);
const guild = arrivalPlayer(game, 'guild');
guild.forces = { 'arrakeen:10': 3 };
guild.reserves = 17;
guild.shipped = true;
const bg = arrivalPlayer(game, 'bg');
bg.forces = { 'carthag:11': 1 };
bg.reserves = 19;
const move = {
type: 'move',
from: 'arrakeen:10',
territory: 'carthag',
sector: 11,
amount: 3,
};
assert.throws(() => applyAction(game, guild.id, move));
bg.advisors = { carthag: {} };
// Advisors need another faction present or automatic stance settlement
// correctly makes this lone group fighters before either quote.
const moritani = arrivalPlayer(game, 'moritani');
moritani.forces = { 'carthag:11': 1 };
moritani.reserves = 19;
assert.equal(botArrivalBlock(ownView(game), move), null);
assert.equal(applyAction(game, guild.id, move).pendingAmbassador?.entrant, guild.id);
});
void test('arrival filtering never reads hidden rival resources or Terror faces and does not mutate the view', () => {
const game = movementArrivalGame();
+3 -85
View File
@@ -2,81 +2,20 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, type Action, type Game } from '../game/engine';
import {
addShipmentArrivalAmbassador,
legacyGuildArrival,
richeseNoFieldArrivalAction,
shipmentArrivalAction,
shipmentArrivalGame,
shipmentArrivalPlayer,
shipmentArrivalReload,
shipmentArrivalResources,
} from './fixture-deferred-shipment-arrival';
const overlap = /Ambassadors combined with another arrival reaction/;
function reject(g: Game, player: string, action: Action, pattern: RegExp) {
function reject(g: Game, player: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, player, action), pattern);
assert.throws(() => applyAction(g, player, action));
assert.deepEqual(g, before);
}
void test('an ordinary Guild shipment rejects a known Ambassador and advisor overlap before declaration or payment', (t) => {
const g = shipmentArrivalGame();
addShipmentArrivalAmbassador(g);
const resources = structuredClone(shipmentArrivalResources(g));
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Unsupported shipment allocated an event');
});
reject(g, 'guild', shipmentArrivalAction, overlap);
assert.deepEqual(shipmentArrivalResources(g), resources);
assert.equal(g.pendingShipment, null);
assert.equal(g.decision, null);
assert.equal(uuid.mock.callCount(), 0);
});
void test('an own Richese No-Field rejects the unsupported arrival before saving its private cancellation response', () => {
const g = shipmentArrivalGame();
g.active = 'richese';
shipmentArrivalPlayer(g, 'richese').shipped = false;
addShipmentArrivalAmbassador(g);
const action = richeseNoFieldArrivalAction(g);
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'richese', action), overlap);
assert.deepEqual(g, before);
assert.equal(g.pendingShipment, null);
assert.equal(g.response, null);
assert.equal(g.decision, null);
assert.equal(shipmentArrivalPlayer(g, 'richese').noField!.deployed, null);
});
void test('a saved Guild allowance returns only the uncommitted ordinary shipment and permits a safe replacement', () => {
const g = legacyGuildArrival();
const before = structuredClone(g);
const resources = structuredClone(shipmentArrivalResources(g));
const returned = applyAction(shipmentArrivalReload(g), 'guild', {
type: 'decision',
allow: true,
});
assert.deepEqual(g, before);
assert.deepEqual(shipmentArrivalResources(returned), resources);
assert.equal(returned.pendingShipment, null);
assert.equal(returned.decision, null);
assert.equal(shipmentArrivalPlayer(returned, 'guild').shipped, false);
assert.deepEqual(shipmentArrivalPlayer(returned, 'guild').forces, {});
assert.match(returned.log.at(-1)!.text, overlap);
assert.match(
returned.log.at(-1)!.text,
/No forces, spice, cards or shipment allowance were spent/,
);
const retried = applyAction(returned, 'guild', {
...shipmentArrivalAction,
territory: 'polar_sink',
sector: 0,
});
assert.equal(retried.decision?.kind, 'guildShipment');
assert.equal(retried.pendingShipment?.territory, 'polar_sink');
});
void test('a supported Guild allowance still commits once and opens the advisor choice', () => {
let g = shipmentArrivalGame();
@@ -99,27 +38,6 @@ void test('a supported Guild allowance still commits once and opens the advisor
void test('a malformed saved shipment remains rejected and is not treated as a recoverable arrival overlap', () => {
const g = legacyGuildArrival();
g.pendingShipment!.cost++;
reject(
g,
'guild',
{ type: 'decision', allow: true },
/declared shipment price is no longer current/,
);
reject(g, 'guild', { type: 'decision', allow: true });
});
void test('forged or null special receipts cannot enter the ordinary legacy return path', () => {
for (const corrupt of [
(g: Game) => {
g.pendingShipment!.ambassadorEvent = 'forged';
},
(g: Game) => {
g.pendingShipment!.guildSecretEvent = null as unknown as string;
},
]) {
const g = legacyGuildArrival();
corrupt(g);
reject(g, 'guild', { type: 'decision', allow: true }, overlap);
assert.ok(g.pendingShipment);
assert.equal(g.decision?.kind, 'guildShipment');
}
});
+219
View File
@@ -0,0 +1,219 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeFactionExpansionsGameForAudit, joinGame,
newPlayer, type Action, type Game,
} from '../game/engine';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
export type BgAmbassadorArrivalStep = { actor: string; action: Action };
export type BgAmbassadorArrivalFixture = {
initial: Game;
afterSetup: Game;
beforePlacement: Game;
beforeShipment: Game;
game: Game;
actions: BgAmbassadorArrivalStep[];
owner: string;
advisor: string;
entrant: string;
territory: string;
location: string;
token: string;
fighters: boolean;
shipmentAction: Action;
movementAction: Action;
staging: string[];
};
export function bgAmbassadorArrivalPlayer(game: Game, actor: string) {
const player = game.players.find(player => player.id === actor);
assert.ok(player, `Missing original BG arrival seat ${actor}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Native entropy only: original shuffles/deals retain their physical inventories.
* No hand, force, wallet, stance, turn, receipt or decision is assigned. */
function entropy<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Original no-spend/no-call driver, including the native owned BG controls. */
export function nextBgAmbassadorArrivalStep(game: Game): BgAmbassadorArrivalStep {
if (!game.phaseOpening && !game.response) {
const bg = game.players.find(player => player.faction === 'beneGesserit')!;
const ecaz = game.players.find(player => player.faction === 'ecaz')!;
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: bg.id, action: { type: 'predict', faction: 'emperor', turn: 10 } };
if (game.status === 'setup' && game.setupStage === 'forces') {
if (ecaz.reserves === 20)
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
if (!bg.advisorSetup)
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'imperial_basin', sector: 10 } };
}
const decision = game.decision;
if (decision?.kind === 'ecazPlacement')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'advisorBattle')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'ecazAmbassador') {
assert.ok(game.pendingAmbassador);
return { actor: decision.player, action: { type: 'decision', event: game.pendingAmbassador.event, decline: true } };
}
}
const step = nextRicheseSkillsTechNativeStep(game);
if (step.action.type === 'stormDial') step.action.amount = 0;
return step;
}
export function applyBgAmbassadorArrivalStep(
game: Game, step: BgAmbassadorArrivalStep, actions?: BgAmbassadorArrivalStep[],
): Game {
actions?.push(structuredClone(step));
return entropy(() => applyAction(game, step.actor, step.action));
}
function advance(game: Game, until: (state: Game) => boolean, actions: BgAmbassadorArrivalStep[]): Game {
for (let n = 0; n < 2400; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'Original BG arrival must remain in its native game');
game = applyBgAmbassadorArrivalStep(game, nextBgAmbassadorArrivalStep(game), actions);
}
throw Error('Original native BG arrival did not reach the requested boundary');
}
/** Finish only original response/acknowledgement windows. In particular this
* never silently chooses Intrusion, accompaniment or the Ambassador offer. */
export function settleBgAmbassadorArrivalControls(game: Game, actions: BgAmbassadorArrivalStep[] = []): Game {
return advance(game, state => !state.response && !state.phaseOpening && !state.pendingTreacheryDiscard, actions);
}
function orderLandBlows(game: Game, staging: string[]) {
for (let at = 0; at < 2; at++) {
const index = game.spiceDeck.findIndex((card, index) => index >= at && 'territory' in card &&
!['imperial_basin', 'carthag'].includes(card.territory));
assert.ok(index >= at, 'Two original undealt land blows must remain');
const card = game.spiceDeck.splice(index, 1)[0];
game.spiceDeck.splice(at, 0, card);
assert.ok('territory' in card);
staging.push(`Turn ${game.turn}: original undealt ${card.territory} land blow moved to slot ${at + 1}.`);
}
}
/** Original Advanced Ecaz/BG/Emperor setup and paid entry, stopped BEFORE ship.
* In the fighter variant the original Imperial Basin advisor actually moves to
* empty Carthag on turn one, becoming a fighter by the native automatic rule.
* Ecaz leaves turn-one placement unused, then marks Carthag on turn two: no
* preceding BG Ambassador trigger or fabricated fighter group is needed.
* The only controlled values are producer entropy, a conserved undealt Karama
* starting-deal slot and unplayed Spice order. Supplied fresh lobbies retain
* their original seat identities; no HTTP authentication claim is made. */
export function createBgAmbassadorArrivalFixture(options: { initial?: Game; fighters?: boolean } = {}): BgAmbassadorArrivalFixture {
let game = options.initial ? structuredClone(options.initial)
: createGame('BGAMBASSADORARRIVAL', newPlayer('bg', 'Bene Gesserit', 'beneGesserit'), true, ['ecaz']);
if (!options.initial) {
joinGame(game, newPlayer('ecaz', 'Ecaz', 'ecaz'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
}
assert.equal(game.status, 'lobby', 'Only an original undealt lobby can enter');
assert.equal(game.advanced, true);
assert.deepEqual(game.expansions, ['ecaz']);
assert.equal(game.players.length, 3);
const owner = game.players.find(player => player.faction === 'ecaz')?.id;
const advisor = game.players.find(player => player.faction === 'beneGesserit')?.id;
const entrant = game.players.find(player => player.faction === 'emperor')?.id;
assert.ok(owner && advisor && entrant, 'Original seats must be Ecaz, Bene Gesserit and Emperor');
const initial = structuredClone(game), actions: BgAmbassadorArrivalStep[] = [], staging: string[] = [];
for (const player of game.players)
if (!player.ready) game = applyBgAmbassadorArrivalStep(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = entropy(() => initializeFactionExpansionsGameForAudit(game));
// Give Ecaz a real cancellation opportunity through its ORIGINAL starting
// deal, not a card transfer into an already dealt hand.
const dealSlot = game.players.findIndex(player => player.id === owner);
const karamaIndex = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(karamaIndex >= 0 && game.players.every(player => !player.hand.length));
const karama = game.deck[karamaIndex].id;
[game.deck[dealSlot], game.deck[karamaIndex]] = [game.deck[karamaIndex], game.deck[dealSlot]];
staging.push(`Original undealt ${karama} swapped into Ecaz's starting-deal slot ${dealSlot + 1}; no dealt hand is changed.`);
game = advance(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
assert.ok(bgAmbassadorArrivalPlayer(game, owner).hand.some(card => card.id === karama));
assert.equal(bgAmbassadorArrivalPlayer(game, advisor).forces['imperial_basin:10'], 1);
assert.ok(bgAmbassadorArrivalPlayer(game, advisor).advisors?.imperial_basin);
game = advance(game, state => state.phase === 1 && clean(state), actions);
orderLandBlows(game, staging);
const fighters = options.fighters ?? false;
if (fighters) {
game = advance(game, state => state.phase === 5 && state.active === advisor && clean(state), actions);
game = applyBgAmbassadorArrivalStep(game, { actor: advisor, action: {
type: 'move', from: 'imperial_basin:10', territory: 'carthag', sector: 11, amount: 1,
} }, actions);
game = settleBgAmbassadorArrivalControls(game, actions);
assert.ok(clean(game));
assert.equal(bgAmbassadorArrivalPlayer(game, advisor).forces['carthag:11'], 1);
assert.equal(bgAmbassadorArrivalPlayer(game, advisor).advisors?.carthag, undefined);
game = advance(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions);
orderLandBlows(game, staging);
}
game = advance(game, state => state.phase === 4 && state.decision?.kind === 'ecazPlacement', actions);
const beforePlacement = structuredClone(game);
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
assert.equal(token.zone, 'supply');
const placementWallet = bgAmbassadorArrivalPlayer(game, owner).spice;
game = applyBgAmbassadorArrivalStep(game, { actor: owner, action: {
type: 'decision', token: token.id, territory: 'carthag',
} }, actions);
game = settleBgAmbassadorArrivalControls(game, actions);
assert.equal(bgAmbassadorArrivalPlayer(game, owner).spice, placementWallet - 1);
assert.equal(game.ecazAmbassadors!.tokens.find(candidate => candidate.id === token.id)!.location, 'carthag');
assert.equal(game.decision?.kind, 'ecazPlacement');
game = applyBgAmbassadorArrivalStep(game, { actor: owner, action: { type: 'decision', decline: true } }, actions);
game = advance(game, state => state.phase === 5 && state.active === entrant && clean(state), actions);
assert.equal(bgAmbassadorArrivalPlayer(game, advisor).forces['carthag:11'] ?? 0, fighters ? 1 : 0);
assert.ok(!game.pendingAmbassador && !game.pendingArrivalReaction?.length);
const beforeShipment = structuredClone(game);
return {
initial, afterSetup, beforePlacement, beforeShipment, game, actions, owner, advisor, entrant,
territory: 'carthag', location: 'carthag:11', token: token.id, fighters, staging,
shipmentAction: { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 },
movementAction: { type: 'move', from: 'carthag:11', territory: 'arsunt', sector: 11, amount: 1 },
};
}
/** First bad transition is intentionally outside the original builder. */
export function beginBgAmbassadorArrival(fixture: BgAmbassadorArrivalFixture): Game {
return settleBgAmbassadorArrivalControls(applyBgAmbassadorArrivalStep(fixture.game, {
actor: fixture.entrant, action: fixture.shipmentAction,
}));
}
export function bgAmbassadorAccompanimentAction(accept: boolean, accompany = true): Action {
return accept ? { type: 'decision', accept: true, accompany, amount: 1 }
: { type: 'decision', accept: false };
}
export function bgAmbassadorIntrusionAction(accept: boolean): Action {
return { type: 'decision', accept };
}
export function bgAmbassadorOfferAction(game: Game, trigger = false): Action {
const entry = game.pendingAmbassador;
assert.ok(entry);
return trigger ? { type: 'decision', event: entry.event, trigger: true, beneficiary: entry.owner, choice: 'duke' }
: { type: 'decision', event: entry.event, decline: true };
}
export function finishBgAmbassadorArrival(game: Game, trigger = false): Game {
const entry = game.pendingAmbassador;
assert.ok(entry);
return settleBgAmbassadorArrivalControls(applyBgAmbassadorArrivalStep(game, {
actor: entry.owner, action: bgAmbassadorOfferAction(game, trigger),
}));
}
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import {
advancePairedE3Nexus, createPairedE3NexusSetup, nextPairedE3NexusStep,
pairedE3NexusClean, pairedE3NexusInventory, pairedE3NexusPlayer,
pairedE3NexusReload, quietPairedE3NexusChoice, stepPairedE3Nexus,
type PairedE3NexusStep, type PairedE3NexusTransition,
} from './fixture-paired-e3-nexus-modules';
import { orderE3NexusEcazSpice } from './fixture-e3-nexus-ecaz';
import { quietPlan } from './fixture-nexus-duke-capture';
/** Original Advanced Ecaz/Moritani/Guild, without optional physical modules.
* The shared setup selects only original undealt entropy. Here the original
* unplayed Treachery deck puts a Karama in the first real auction; Ecaz buys
* that physical card for one spice. Only the conserved, unplayed Spice deck
* is ordered for land blows on turn one and an actual Nexus on turn two.
* No hand, wallet, army, leader, phase, alliance or response is staged.
*
* The expansion/Nexus admission guard deliberately excludes the classic-only
* Guild rate-cancellation window. Actual native half price and its separate
* income/counter window are retained, not widened by this fixture. */
export function createGuildAmbassadorIncomeArrival() {
const setup = createPairedE3NexusSetup({ roster: ['ecaz', 'moritani', 'guild'],
advanced: true, skills: false, homeworlds: false, discovery: false,
tech: false, strongholds: false });
const actions = setup.actions;
const ecaz = setup.ecaz, entrant = setup.moritani;
const guild = setup.afterSetup.players.find(p => p.faction === 'guild')!.id;
let game = pairedE3NexusReload(setup.afterSetup);
const karamaIndex = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(karamaIndex >= 0);
const karama = game.deck.splice(karamaIndex, 1)[0];
game.deck.unshift(karama);
orderE3NexusEcazSpice(game);
game = advancePairedE3Nexus(game, s => s.phase === 3 && pairedE3NexusClean(s) &&
s.auction?.active === ecaz && s.auction.index === 0, actions, quietPairedE3NexusChoice);
const purchaseBefore = pairedE3NexusReload(game);
const bid: PairedE3NexusStep = { actor: ecaz, action: { type: 'bid', amount: 1 } };
game = stepPairedE3Nexus(game, bid, actions);
game = advancePairedE3Nexus(game, s => pairedE3NexusPlayer(s, ecaz).hand.some(c => c.id === karama.id) &&
pairedE3NexusClean(s), actions, quietPairedE3NexusChoice);
const purchase = { before: purchaseBefore, step: bid, after: pairedE3NexusReload(game) };
game = advancePairedE3Nexus(game, s => s.turn === 2 && s.phase === 1 && pairedE3NexusClean(s),
actions, quietPairedE3NexusChoice);
orderE3NexusEcazSpice(game, true);
game = advancePairedE3Nexus(game, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution &&
pairedE3NexusClean(s), actions, quietPairedE3NexusChoice);
const beforeAlliance = pairedE3NexusReload(game);
for (const [actor, target] of [[entrant, guild], [guild, entrant]])
game = stepPairedE3Nexus(game, { actor, action: { type: 'alliance', target } }, actions);
const alliance = pairedE3NexusReload(game);
const native = quietPlan(game);
const token = native.token, destination = native.destination;
const sector = territory(destination).sectors.find(s => s !== game.storm)!;
assert.ok(Number.isInteger(sector));
game = advancePairedE3Nexus(game, s => !s.response && !s.phaseOpening &&
s.decision?.kind === 'ecazPlacement', actions, quietPairedE3NexusChoice);
const placementBefore = pairedE3NexusReload(game);
const place: PairedE3NexusStep = { actor: ecaz,
action: { type: 'decision', token, territory: destination } };
game = stepPairedE3Nexus(game, place, actions);
game = advancePairedE3Nexus(game, s => !s.response &&
s.ecazAmbassadors!.tokens.find(t => t.id === token)!.zone === 'placed',
actions, quietPairedE3NexusChoice);
const placement = { before: placementBefore, step: place, after: pairedE3NexusReload(game) };
game = advancePairedE3Nexus(game, s => s.phase === 5 && s.active === entrant && pairedE3NexusClean(s),
actions, quietPairedE3NexusChoice);
const source = Object.entries(pairedE3NexusPlayer(game, entrant).forces)
.find(([key, n]) => n > 0 && Number(key.split(':')[1]) !== game.storm &&
key.split(':')[0] !== destination)?.[0];
assert.ok(source, 'The native Moritani setup must retain an original on-board source');
assert.equal(pairedE3NexusPlayer(game, entrant).ally, guild);
assert.equal(pairedE3NexusPlayer(game, guild).ally, entrant);
assert.equal(pairedE3NexusPlayer(game, ecaz).ally, null);
assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token)!.effect, 'ecaz');
assert.ok(pairedE3NexusPlayer(game, ecaz).hand.some(c => c.id === karama.id));
pairedE3NexusInventory(game);
const action: Action = { type: 'guildShip', from: source, amount: 1, elite: 0,
territory: destination, sector, allyPayment: 0 };
return { ...setup, ecaz, entrant, guild, source, destination, sector,
target: `${destination}:${sector}`, token, karama: karama.id, purchase,
beforeAlliance, alliance, placement, beforeAction: pairedE3NexusReload(game),
action, actions, provenance: [
'Shared original undealt setup entropy; no played physical state assigned.',
'Conserved unplayed Treachery deck: first original Karama auctioned and paid by Ecaz.',
'Conserved unplayed Spice deck: turn-one land blows, turn-two native worm/Nexus.',
] };
}
/** Stop at the real income/counter window, after physical transport but before entry. */
export function declareGuildAmbassadorIncomeArrival(fixture: {
beforeAction: Game; entrant: string; action: Action;
}): PairedE3NexusTransition {
const before = pairedE3NexusReload(fixture.beforeAction);
const step = { actor: fixture.entrant, action: fixture.action };
const after = stepPairedE3Nexus(before, step);
assert.equal(after.response?.kind, 'guildIncome');
assert.equal(after.pendingArrivalReaction?.length, 1);
assert.ok(!after.pendingAmbassador);
return { before, step, after };
}
/** Pass the actual persisted income queue; do not synthesize or clear controls. */
export function allowGuildAmbassadorIncomeArrival(state: Game) {
let game = pairedE3NexusReload(state);
const actions: PairedE3NexusStep[] = [];
for (let n = 0; game.response && n < 20; n++) {
assert.equal(game.response.kind, 'guildIncome');
const step = nextPairedE3NexusStep(game); assert.ok(step);
assert.equal(step.action.type, 'passResponse');
game = stepPairedE3Nexus(game, step, actions);
}
assert.equal(game.response, null);
assert.equal(game.decision?.kind, 'ecazAmbassador');
assert.equal(game.pendingAmbassador?.stage, 'offer');
return { game, actions };
}
/** Spend the actual auctioned card through the original income cancellation. */
export function cancelGuildAmbassadorIncomeArrival(state: Game, actor: string, card: string) {
const before = pairedE3NexusReload(state);
assert.equal(before.response?.kind, 'guildIncome');
const controls = viewGame(before, actor).responseControls;
assert.ok(controls && !controls.hasPassed && controls.cancelCards.includes(card));
const step: PairedE3NexusStep = { actor, action: { type: 'card', mode: 'cancel', card } };
const after = stepPairedE3Nexus(before, step);
assert.equal(after.response, null);
assert.equal(after.decision?.kind, 'ecazAmbassador');
assert.equal(after.pendingAmbassador?.stage, 'offer');
return { before, step, after };
}
export function guildAmbassadorIncomeChoice(game: Game, trigger = true): PairedE3NexusStep {
assert.equal(game.decision?.kind, 'ecazAmbassador');
const entry = game.pendingAmbassador!;
return { actor: game.decision.player, action: { type: 'decision', event: entry.event,
...(trigger ? { trigger: true, beneficiary: game.decision.player, choice: 'duke' } : { decline: true }) } };
}
/** A single original offer completion, then the original entrant's legal next action. */
export function completeGuildAmbassadorIncomeArrival(state: Game, trigger = true) {
const before = pairedE3NexusReload(state), step = guildAmbassadorIncomeChoice(before, trigger);
const after = stepPairedE3Nexus(before, step);
assert.ok(pairedE3NexusClean(after));
const continuation: PairedE3NexusStep = { actor: after.active!, action: { type: 'endMovement' } };
return { before, step, after, continuation };
}
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botArrivalBlock } from '../game/bot-arrival';
import { guildTransportQuote } from '../game/transport-quote';
import {
allowGuildAmbassadorIncomeArrival, cancelGuildAmbassadorIncomeArrival,
completeGuildAmbassadorIncomeArrival,
createGuildAmbassadorIncomeArrival, declareGuildAmbassadorIncomeArrival,
guildAmbassadorIncomeChoice,
} from './fixture-guild-ambassador-income-arrival';
import { pairedE3NexusPolicy, pairedE3NexusInventory as guildAmbassadorIncomeInventory,
pairedE3NexusPlayer as player, pairedE3NexusReload as reload } from './fixture-paired-e3-nexus-modules';
function rejectUnchanged(game: Game, actor: string, action: Parameters<typeof applyAction>[2]) {
const before = reload(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
for (const trigger of [true, false]) {
for (const cancelIncome of [false, true]) {
void test(`native allied Guild cross-transport ${cancelIncome ? 'cancels' : 'allows'} its real income before Ecaz ${trigger ? 'acquires Duke' : 'declines entry'}`, () => {
const f = createGuildAmbassadorIncomeArrival();
const start = f.beforeAction, payer = player(start, f.entrant), guild = player(start, f.guild);
assert.equal(start.turn, 2);
assert.equal(start.phase, 5);
assert.equal(start.active, f.entrant);
assert.equal(payer.shipped, false);
assert.equal(payer.moved, 0);
assert.equal(payer.ally, f.guild);
assert.equal(guild.ally, f.entrant);
assert.equal(f.beforeAlliance.nexus, true);
assert.equal(player(f.beforeAlliance, f.entrant).ally, null);
assert.equal(player(f.alliance, f.entrant).ally, f.guild);
assert.equal(player(f.purchase.after, f.ecaz).spice, player(f.purchase.before, f.ecaz).spice - 1);
assert.equal(player(f.placement.after, f.ecaz).spice, player(f.placement.before, f.ecaz).spice - 1);
assert.ok(player(start, f.ecaz).hand.some(c => c.id === f.karama));
assert.equal(start.ecazAmbassadors!.tokens.find(t => t.id === f.token)!.zone, 'placed');
assert.equal(start.ecazAmbassadors!.tokens.find(t => t.id === f.token)!.effect, 'ecaz');
const view = viewGame(reload(start), f.entrant);
const quote = guildTransportQuote(view, f.action);
assert.deepEqual(quote.unavailableReasons, []);
assert.deepEqual(quote.quote, { cost: 1, normalCost: 1, ownPayment: 1, pledgedPayment: 0 });
assert.equal(quote.physicalForces, 1);
assert.equal(botArrivalBlock(view, f.action), null,
'A future earned receipt is not an already-active arrival response');
// This original engine path already commits; owner previews must agree.
const declaration = declareGuildAmbassadorIncomeArrival(f);
const paid = declaration.after;
assert.ok(!paid.pendingGuildTransport);
assert.equal(paid.response?.kind, 'guildIncome');
assert.equal(paid.response?.amount, 1);
assert.equal(paid.response?.owner, f.guild);
assert.equal(player(paid, f.entrant).spice, payer.spice - 1);
assert.equal(player(paid, f.guild).spice, guild.spice);
assert.equal(player(paid, f.entrant).reserves, payer.reserves);
assert.equal(player(paid, f.entrant).forces[f.source], payer.forces[f.source] - 1);
assert.equal(player(paid, f.entrant).forces[f.target], (payer.forces[f.target] ?? 0) + 1);
assert.equal(player(paid, f.entrant).shipped, true);
assert.equal(player(paid, f.entrant).moved, payer.moved);
assert.equal(paid.active, f.entrant);
assert.deepEqual(paid.pendingArrivalReaction?.map(e => ({ entrant: e.entrant, territory: e.territory,
sector: e.sector, amount: e.amount, cause: e.cause, turn: e.turn, phase: e.phase })),
[{ entrant: f.entrant, territory: f.destination, sector: f.sector,
amount: 1, cause: 'guildTransport', turn: start.turn, phase: 5 }]);
assert.ok(!paid.pendingAmbassador);
const counter = viewGame(reload(paid), f.ecaz).responseControls;
assert.ok(counter && !counter.hasPassed && counter.cancelCards.includes(f.karama));
rejectUnchanged(paid, f.entrant, f.action);
rejectUnchanged(paid, f.entrant, { type: 'endMovement' });
guildAmbassadorIncomeInventory(paid);
const offered = cancelIncome
? cancelGuildAmbassadorIncomeArrival(reload(paid), f.ecaz, f.karama).after
: allowGuildAmbassadorIncomeArrival(reload(paid)).game;
assert.equal(offered.response, null);
assert.equal(offered.pendingArrivalReaction, null);
assert.equal(offered.decision?.kind, 'ecazAmbassador');
assert.equal(offered.pendingAmbassador?.entrant, f.entrant);
assert.equal(offered.pendingAmbassador?.token, f.token);
assert.equal(offered.pendingAmbassador?.territory, f.destination);
assert.equal(offered.pendingAmbassador?.stage, 'offer');
assert.equal(player(offered, f.guild).spice, guild.spice + (cancelIncome ? 0 : 1));
assert.equal(player(offered, f.entrant).spice, payer.spice - 1);
assert.deepEqual(player(offered, f.entrant).forces, player(paid, f.entrant).forces);
assert.equal(offered.discard.filter(c => c.id === f.karama).length, cancelIncome ? 1 : 0);
assert.equal(player(offered, f.ecaz).hand.some(c => c.id === f.karama), !cancelIncome);
assert.equal(offered.active, f.entrant);
rejectUnchanged(offered, f.entrant, guildAmbassadorIncomeChoice(offered, trigger).action);
const completed = completeGuildAmbassadorIncomeArrival(reload(offered), trigger);
const done = completed.after;
assert.equal(done.pendingAmbassador, null);
assert.equal(done.pendingArrivalReaction, null);
assert.equal(done.response, null);
assert.equal(done.decision, null);
assert.equal(done.active, f.entrant);
assert.equal(done.turn, start.turn);
assert.equal(done.phase, start.phase);
assert.equal(player(done, f.entrant).moved, payer.moved);
assert.equal(player(done, f.entrant).shipped, true);
assert.deepEqual(player(done, f.entrant).forces, player(paid, f.entrant).forces);
assert.equal(player(done, f.entrant).reserves, payer.reserves);
assert.equal(player(done, f.entrant).spice, payer.spice - 1);
assert.equal(player(done, f.guild).spice, guild.spice + (cancelIncome ? 0 : 1));
assert.equal(done.ecazAmbassadors!.tokens.find(t => t.id === f.token)!.zone, trigger ? 'supply' : 'placed');
assert.deepEqual(done.ecazAmbassadors!.cohort, start.ecazAmbassadors!.cohort);
assert.equal(done.dukeVidal!.controller, trigger ? f.ecaz : start.dukeVidal!.controller);
if (trigger) assert.equal(done.dukeVidal!.source, 'ecaz');
assert.deepEqual(done.players.map(p => p.hand), offered.players.map(p => p.hand));
assert.deepEqual(done.players.map(p => p.leaders), offered.players.map(p => p.leaders));
assert.deepEqual(done.deck, offered.deck);
assert.deepEqual(done.discard, offered.discard);
rejectUnchanged(done, completed.step.actor, completed.step.action);
rejectUnchanged(done, f.entrant, f.action);
guildAmbassadorIncomeInventory(done);
const next = applyAction(reload(done), completed.continuation.actor, completed.continuation.action);
assert.notEqual(next.active, f.entrant);
assert.equal(next.pendingAmbassador, null);
assert.equal(next.pendingArrivalReaction, null);
assert.deepEqual(player(next, f.entrant).forces, player(done, f.entrant).forces);
assert.equal(player(next, f.entrant).spice, payer.spice - 1);
assert.equal(player(next, f.guild).spice, guild.spice + (cancelIncome ? 0 : 1));
assert.equal(next.discard.filter(c => c.id === f.karama).length, cancelIncome ? 1 : 0);
guildAmbassadorIncomeInventory(next);
});
}
}
void test('actual Ecaz owner-view policies complete the queued native Guild entry without replaying payment or counters', () => {
const f = createGuildAmbassadorIncomeArrival();
const paid = declareGuildAmbassadorIncomeArrival(f).after;
const offered = allowGuildAmbassadorIncomeArrival(paid).game;
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const action = pairedE3NexusPolicy(reload(offered), f.ecaz, difficulty)[0];
assert.ok(action, `The actual ${difficulty} Ecaz owner needs a legal response`);
const done = applyAction(reload(offered), f.ecaz, action);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.pendingArrivalReaction, null);
assert.equal(done.decision, null);
assert.equal(done.response, null);
assert.equal(done.active, f.entrant);
assert.deepEqual(player(done, f.entrant).forces, player(paid, f.entrant).forces);
assert.equal(player(done, f.entrant).spice, player(paid, f.entrant).spice);
assert.equal(player(done, f.guild).spice, player(paid, f.guild).spice + 1);
assert.equal(player(done, f.entrant).moved, player(paid, f.entrant).moved);
guildAmbassadorIncomeInventory(done);
const next = applyAction(reload(done), f.entrant, { type: 'endMovement' });
assert.notEqual(next.active, f.entrant);
assert.deepEqual(player(next, f.entrant).forces, player(paid, f.entrant).forces);
}
});
+54 -102
View File
@@ -89,14 +89,8 @@ void test('supported simultaneous Ambassador and Terror arrival quotes one bound
retiresOrnithopter: false,
});
assert.deepEqual(input, before);
assert.throws(
() => quoteCompletedMovementArrival({ ...input, overlapSupported: false }),
/Ambassadors combined with another arrival reaction/,
);
assert.throws(
() => quoteCompletedMovementArrival({ ...input, overlapSupported: undefined }),
/Ambassadors combined with another arrival reaction/,
);
assert.throws(() => quoteCompletedMovementArrival({ ...input, overlapSupported: false }));
assert.throws(() => quoteCompletedMovementArrival({ ...input, overlapSupported: undefined }));
assert.equal(
quoteCompletedMovementArrival({ ...input, terror: [] }).reaction,
'ambassador',
@@ -107,7 +101,7 @@ void test('supported simultaneous Ambassador and Terror arrival quotes one bound
);
});
void test('overlap support does not bypass competing controls, fighter response, or BG intrusion', () => {
void test('overlap support does not bypass actual competing controls or the pre-entry fighter response', () => {
const input = {
...fixture(),
overlapSupported: true,
@@ -120,33 +114,16 @@ void test('overlap support does not bypass competing controls, fighter response,
'pendingTerror',
'pendingAmbassador',
] as const)
assert.throws(
() =>
assert.throws(() =>
quoteCompletedMovementArrival({
...input,
controls: { ...input.controls, [flag]: true },
}), flag);
assert.throws(() =>
quoteCompletedMovementArrival({
...input,
controls: { ...input.controls, [flag]: true },
}),
/Ambassadors combined with another arrival reaction/,
flag,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
order: { ...input.order, wantsFighters: true },
}),
/Ambassadors combined with another arrival reaction/,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
players: input.players.map((p) =>
p.id === 'b' ? { ...p, forces: { 'broken_land:9': 1 } } : p,
),
}),
/Ambassadors combined with another arrival reaction/,
);
order: { ...input.order, wantsFighters: true },
}));
});
void test('overlap support never turns same-territory movement or shared ownership into an entry', () => {
@@ -173,16 +150,13 @@ void test('overlap support never turns same-territory movement or shared ownersh
}).reaction,
null,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
players: input.players.map((p) =>
p.id === 'm' ? { ...p, id: 'e' } : p,
),
}),
/Ambassadors combined with another arrival reaction/,
);
assert.throws(() =>
quoteCompletedMovementArrival({
...input,
players: input.players.map((p) =>
p.id === 'm' ? { ...p, id: 'e' } : p,
),
}));
});
for (const exclusion of [
@@ -261,10 +235,6 @@ void test('BG intrusion uses current opposing fighters, requires Advanced rules
quoteCompletedMovementArrival({ ...input, players: advisors }).intrusion,
false,
);
assert.throws(
() => quoteCompletedMovementArrival({ ...input, players, ambassadors: [ambassador] }),
/Ambassadors combined/,
);
// A Terror entry behind the pending Intrusion is now committed and deferred.
assert.equal(
quoteCompletedMovementArrival({ ...input, players, terror: [terror] }).reaction,
@@ -341,15 +311,12 @@ void test('current controls and the move-created fighter response are checked wi
'pendingTerror',
'pendingAmbassador',
] as const)
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
ambassadors: [ambassador],
controls: { ...input.controls, [flag]: true },
}),
/Ambassadors combined/,
);
assert.throws(() =>
quoteCompletedMovementArrival({
...input,
ambassadors: [ambassador],
controls: { ...input.controls, [flag]: true },
}));
for (const flag of ['response', 'decision'] as const)
assert.equal(
quoteCompletedMovementArrival({
@@ -360,15 +327,12 @@ void test('current controls and the move-created fighter response are checked wi
'terror',
`A committed ${flag} arrival defers its Terror entry instead of rejecting it.`,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
terror: [terror],
controls: { ...input.controls, response: true, pendingTerror: true },
}),
/Resolve the pending Terror entry first/,
);
assert.throws(() =>
quoteCompletedMovementArrival({
...input,
terror: [terror],
controls: { ...input.controls, response: true, pendingTerror: true },
}));
assert.equal(
quoteCompletedMovementArrival({
...input,
@@ -377,15 +341,12 @@ void test('current controls and the move-created fighter response are checked wi
}).reaction,
'terror',
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
ambassadors: [ambassador],
order: { ...input.order, wantsFighters: true },
}),
/Ambassadors combined/,
);
assert.throws(() =>
quoteCompletedMovementArrival({
...input,
ambassadors: [ambassador],
order: { ...input.order, wantsFighters: true },
}));
assert.equal(
quoteCompletedMovementArrival({
...input,
@@ -443,24 +404,18 @@ void test('paid Box prevents only actual Ornithopter retirement: first two-group
}).retiresOrnithopter,
false,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
controls,
flight: { player: 'p', mode: 'twoGroups', completed: 1 },
}),
/Finish the paid search/,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
controls,
flight: { player: 'p', mode: 'range3', completed: 0 },
}),
/Finish the paid search/,
);
assert.throws(() =>
quoteCompletedMovementArrival({
...input,
controls,
flight: { player: 'p', mode: 'twoGroups', completed: 1 },
}));
assert.throws(() =>
quoteCompletedMovementArrival({
...input,
controls,
flight: { player: 'p', mode: 'range3', completed: 0 },
}));
assert.equal(
quoteCompletedMovementArrival({
...input,
@@ -675,15 +630,12 @@ for (const form of ['printed', 'worthless'] as const)
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Rejected arrival preflight created an event.');
});
assert.throws(
() =>
applyAction(g, form === 'printed' ? 'p' : 'b', {
type: 'card',
mode: 'cancel',
card: form === 'printed' ? printed : worthless,
}),
/Ambassadors combined with another arrival reaction/,
);
assert.throws(() =>
applyAction(g, form === 'printed' ? 'p' : 'b', {
type: 'card',
mode: 'cancel',
card: form === 'printed' ? printed : worthless,
}));
assert.deepEqual(g, before);
assert.deepEqual(physical(g), inventory);
assert.equal(g.pendingKarama ?? null, null);