Files
Dune/tests/ecaz-hms-ambassador-engine.test.ts
T
Quintine 52832dc0fd Allow Ecaz Ambassadors on the placed mobile stronghold
Share dynamic stronghold legality across placement, cancellation, bots and controls. Keep the HMS sheltered, preserve original fees and entry effects, and display its public Ambassador attached to the stronghold interior.

Cover genuine Basic/Advanced placement, paid movement entry, card continuations, prevention and native relocation. No Terror or direct-shipment permission is added.
2026-10-24 17:55:16 +11:00

174 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { GRAPH, MOBILE_LOCATION, MOBILE_STRONGHOLD, splitLocation, territory } from '../game/board';
import {
actEcazHmsAmbassador as act, advanceEcazHmsAmbassador as advance,
createEcazHmsAmbassadorFixture, ecazHmsAmbassadorClean as clean,
ecazHmsAmbassadorInventory as inventory, ecazHmsAmbassadorPlayer as player,
enterEcazHmsAmbassador, passEcazHmsAmbassadorResponses as passResponses, placeEcazHmsAmbassador,
type EcazHmsAmbassadorStep,
} from './fixture-ecaz-hms-ambassador';
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode}: native public HMS setup permits paid end-Revival Ambassador placement`, () => {
const fixture = createEcazHmsAmbassadorFixture({ advanced });
const before = fixture.game, actions: EcazHmsAmbassadorStep[] = [];
assert.equal(fixture.setup.status, 'setup');
assert.deepEqual(fixture.setup.players.map(player => player.faction), ['ecaz', 'ixians', 'guild']);
assert.equal(fixture.privateChoice.before.decision?.kind, 'ixSetup');
assert.equal(fixture.hmsPlacement.before.decision?.kind, 'mobileStronghold');
assert.equal(fixture.hmsPlacement.before.stormResolution, null);
assert.equal(fixture.hmsPlacement.after.mobileStronghold?.location, fixture.point);
assert.equal(before.phase, 4); assert.equal(before.turn, 1);
assert.equal(before.decision?.kind, 'ecazPlacement');
assert.equal(player(before, fixture.ix).forces[MOBILE_LOCATION], 6);
assert.equal(player(before, fixture.ix).elites?.forces[MOBILE_LOCATION], 3);
assert.ok(viewGame(before, fixture.ecaz).ambassadors!.destinations.some(destination =>
destination.id === MOBILE_STRONGHOLD && destination.sheltered));
const game = placeEcazHmsAmbassador(fixture, actions);
assert.equal(player(game, fixture.ecaz).spice, player(before, fixture.ecaz).spice - 1);
assert.deepEqual(game.ecazAmbassadors!.placement, { turn: 1, count: 1, blocked: false });
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.location, MOBILE_STRONGHOLD);
assert.equal(game.mobileStronghold?.location, fixture.point);
assert.deepEqual(inventory(game), fixture.originalInventory);
const snapshot = structuredClone(game);
assert.throws(() => act(game, fixture.ecaz, fixture.placement));
assert.deepEqual(game, snapshot, 'Replaying the placement cannot charge or move a token again');
assert.ok(!viewGame(game, fixture.ecaz).ambassadors!.destinations.some(destination => destination.id === MOBILE_STRONGHOLD));
});
void test(`${mode}: a legitimately storm-pointed HMS shelters a placed Ambassador`, () => {
const fixture = createEcazHmsAmbassadorFixture({ advanced, inStorm: true });
assert.equal(splitLocation(fixture.point).sector, fixture.game.storm);
const game = placeEcazHmsAmbassador(fixture);
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.location, MOBILE_STRONGHOLD);
assert.equal(player(game, fixture.ix).forces[MOBILE_LOCATION], 6);
assert.equal(player(game, fixture.ix).elites?.forces[MOBILE_LOCATION], 3);
assert.equal(player(game, fixture.ecaz).spice, player(fixture.game, fixture.ecaz).spice - 1);
});
void test(`${mode}: actual paid Guild entry may decline or trigger the HMS Emperor Ambassador once`, () => {
const fixture = createEcazHmsAmbassadorFixture({ advanced });
const placed = placeEcazHmsAmbassador(fixture);
const continuation: EcazHmsAmbassadorStep[] = [];
const arrival = enterEcazHmsAmbassador(placed, fixture, continuation), before = arrival.game;
assert.equal(player(arrival.shipment.after, fixture.guild).spice,
player(arrival.shipment.before, fixture.guild).spice - 1);
assert.equal(player(arrival.shipment.after, fixture.guild).shipped, true);
assert.equal(player(before, fixture.guild).moved, 1);
assert.equal(before.active, fixture.guild);
assert.equal(before.pendingAmbassador?.token, fixture.token);
assert.equal(viewGame(before, fixture.ecaz).decision?.kind, 'ecazAmbassador');
assert.equal(viewGame(before, fixture.guild).decision?.player, fixture.ecaz);
const originalArmy = structuredClone(player(before, fixture.guild));
for (const trigger of [false, true]) {
let game = act(before, fixture.ecaz, trigger
? { type: 'decision', event: arrival.event, trigger: true, beneficiary: fixture.ecaz }
: { type: 'decision', event: arrival.event, decline: true });
game = advance(game, clean);
assert.equal(game.pendingAmbassador, null);
assert.equal(player(game, fixture.ecaz).spice, player(before, fixture.ecaz).spice + (trigger ? 5 : 0));
const marker = game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)!;
assert.equal(marker.zone, trigger ? 'used' : 'placed');
assert.equal(marker.location, trigger ? null : MOBILE_STRONGHOLD);
assert.deepEqual(player(game, fixture.guild), originalArmy, 'The original shipment, payment and movement do not replay');
const snapshot = structuredClone(game);
assert.throws(() => act(game, fixture.ecaz,
{ type: 'decision', event: arrival.event, trigger: true, beneficiary: fixture.ecaz }));
assert.deepEqual(game, snapshot);
game = act(game, fixture.guild, fixture.endMovement);
assert.ok(!game.movementRemaining?.includes(fixture.guild));
assert.equal(player(game, fixture.guild).forces[MOBILE_LOCATION], 1);
assert.deepEqual(inventory(game), fixture.originalInventory);
}
});
void test(`${mode}: an untriggered Ambassador stays on the HMS through its real next-Storm relocation`, () => {
const fixture = createEcazHmsAmbassadorFixture({ advanced });
const actions: EcazHmsAmbassadorStep[] = [];
let game = placeEcazHmsAmbassador(fixture, actions);
game = act(game, fixture.ecaz, fixture.finishPlacement, actions);
game = advance(game, state => state.turn === 2 && !state.response && !state.phaseOpening &&
state.decision?.kind === 'mobileStronghold' && !state.decision.placement, actions);
assert.equal(game.mobileStronghold?.location, fixture.point);
const destination = GRAPH[fixture.point].find(key => splitLocation(key).sector !== game.storm &&
territory(splitLocation(key).territory).type !== 'stronghold');
assert.ok(destination);
const before = structuredClone(game), route = [fixture.point, destination];
game = act(game, fixture.ix, { type: 'decision', route, collect: false }, actions);
game = passResponses(game, actions);
assert.equal(game.mobileStronghold?.location, destination);
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.location, MOBILE_STRONGHOLD);
assert.ok(!game.ecazAmbassadors!.tokens.some(token => token.location === fixture.point));
assert.deepEqual(player(game, fixture.ix).forces, player(before, fixture.ix).forces);
assert.deepEqual(player(game, fixture.ix).elites, player(before, fixture.ix).elites);
assert.deepEqual(inventory(game), fixture.originalInventory);
});
}
void test('original held Karama cancels the first HMS placement without paying or moving its token', () => {
const fixture = createEcazHmsAmbassadorFixture({ karama: true });
assert.ok(fixture.karama);
assert.ok(player(fixture.privateChoice.after, fixture.ix).hand.some(card => card.id === fixture.karama));
const declared = act(fixture.game, fixture.ecaz, fixture.placement);
assert.equal(declared.response?.kind, 'ecazPlacement');
assert.deepEqual(declared.pendingEcazPlacement, {
token: fixture.token, territory: MOBILE_STRONGHOLD, turn: 1, cost: 1,
});
assert.equal(player(declared, fixture.ecaz).spice, player(fixture.game, fixture.ecaz).spice);
let game = act(declared, fixture.ix, { type: 'card', card: fixture.karama, mode: 'cancel' });
game = advance(game, state => state.phase === 5 && clean(state));
assert.equal(player(game, fixture.ecaz).spice, player(fixture.game, fixture.ecaz).spice);
const marker = game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)!;
assert.equal(marker.zone, 'supply'); assert.equal(marker.location, null);
assert.deepEqual(game.ecazAmbassadors!.placement, { turn: 1, count: 0, blocked: true });
assert.equal(game.ecazPlacementTurn, 1);
assert.ok(game.discard.some(card => card.id === fixture.karama));
assert.equal(game.mobileStronghold?.location, fixture.point);
assert.deepEqual(inventory(game), fixture.originalInventory);
});
void test('a real HMS placement response retains the original fee and commits after its actual passes', () => {
const fixture = createEcazHmsAmbassadorFixture({ karama: true });
let game = act(fixture.game, fixture.ecaz, fixture.placement);
const beforeSpice = player(game, fixture.ecaz).spice;
assert.equal(game.response?.kind, 'ecazPlacement');
game = act(game, fixture.ecaz, { type: 'passResponse' });
assert.equal(player(game, fixture.ecaz).spice, beforeSpice);
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.zone, 'supply');
game = passResponses(game);
assert.equal(player(game, fixture.ecaz).spice, beforeSpice - 1);
assert.equal(game.ecazAmbassadors!.placement?.count, 1);
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.location, MOBILE_STRONGHOLD);
});
for (const effect of ['choam', 'ixians'] as const) {
void test(`actual HMS ${effect} Ambassador discard finishes its own card effect and original entrant`, () => {
const fixture = createEcazHmsAmbassadorFixture({ effect });
const arrival = enterEcazHmsAmbassador(placeEcazHmsAmbassador(fixture), fixture);
let game = act(arrival.game, fixture.ecaz, {
type: 'decision', event: arrival.event, trigger: true, beneficiary: fixture.ecaz,
});
assert.equal(game.pendingAmbassador?.stage, 'cards');
assert.equal(game.pendingAmbassador.effect, effect);
const before = structuredClone(game), card = player(game, fixture.ecaz).hand[0]; assert.ok(card);
game = act(game, fixture.ecaz, { type: 'decision', event: arrival.event, cards: [card.id] });
game = advance(game, clean);
assert.equal(game.pendingAmbassador, null);
assert.ok(game.discard.some(discarded => discarded.id === card.id));
assert.equal(player(game, fixture.ecaz).spice,
player(before, fixture.ecaz).spice + (effect === 'choam' ? 3 : 0));
assert.equal(player(game, fixture.ecaz).hand.length,
player(before, fixture.ecaz).hand.length - (effect === 'choam' ? 1 : 0));
assert.equal(game.deck.length, before.deck.length - (effect === 'ixians' ? 1 : 0));
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.zone, 'used');
assert.deepEqual(player(game, fixture.guild), player(before, fixture.guild));
assert.deepEqual(inventory(game), fixture.originalInventory);
game = act(game, fixture.guild, fixture.endMovement);
assert.ok(!game.movementRemaining?.includes(fixture.guild));
assert.equal(player(game, fixture.guild).forces[MOBILE_LOCATION], 1);
});
}