Connect native Guild and BG own-arrival continuations
This commit is contained in:
1 parent
e265af3cb3
commit
c10fe0b48c
19 files changed
+1051
-268
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user