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.
This commit is contained in:
Quintine committed 2026-10-24 17:55:16 +11:00
1 parent 659e2a4a9a
commit 52832dc0fd
16 files changed
+1129 -55

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { AmbassadorBoardMarkers } from '../components/ambassador-board-markers';
import {
AmbassadorInspector,
AmbassadorSupply,
EcazPlacement,
EcazPlacementChoice,
} from '../components/ecaz-ambassadors';
import { MOBILE_STRONGHOLD, territory } from '../game/board';
import { faction } from '../game/catalog';
import { createAmbassadors, type AmbassadorToken } from '../game/ecaz-ambassadors';
import {
createGame,
joinGame,
newPlayer,
viewGame,
type Action,
type GameView,
} from '../game/engine';
// These projected-state fixtures isolate the client contract. The source suite
// separately starts Ixians/Ecaz and performs actual HMS placement and entry.
function projected(placed = true): GameView {
const game = createGame('ECAZHMSCONTROLS', newPlayer('e', 'Ecaz', 'ecaz'), false, ['ix', 'ecaz']);
joinGame(game, newPlayer('i', 'Ixians', 'ixians'));
Object.assign(game, {
status: 'playing', phase: 4, turn: 3, storm: 9,
order: ['e', 'i'], decision: { kind: 'ecazPlacement', player: 'e' },
mobileStronghold: { location: placed ? 'imperial_basin:9' : null },
ecazAmbassadors: createAmbassadors(() => 0),
});
game.players[0].spice = 6;
const view = viewGame(game, 'e');
view.ambassadors!.nextCost = 2;
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(EcazPlacement, {
game, busy, act() {},
}));
}
function click(node: ReactNode, selector: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{
children?: ReactNode;
onClick?: () => void;
'data-testid'?: string;
}>(child)) continue;
if (child.props['data-testid'] === selector && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children, selector)) return true;
}
return false;
}
function choose(
game: GameView,
selectedToken = game.ambassadors!.tokens.find((token) => token.zone === 'supply')!.id,
selectedTerritory = MOBILE_STRONGHOLD,
busy = false,
finish = false,
): Action | undefined {
let action: Action | undefined;
const tree = EcazPlacementChoice({
game, busy, id: 'ecaz-placement-test', selectedToken, selectedTerritory,
onSelectToken() {}, onSelectTerritory() {},
act(value) { action = value; },
});
click(tree, finish ? 'ecaz-ambassador-placement-finish' : 'ecaz-ambassador-placement-submit');
return action;
}
void test('placement offers a projected placed HMS as sheltered even when its attached sector is in the storm', () => {
const view = projected();
assert.ok(view.ambassadors!.destinations.some((destination) =>
destination.id === MOBILE_STRONGHOLD && destination.sheltered,
));
const html = markup(view);
assert.match(html, /value="hidden_mobile_stronghold">Hidden Mobile Stronghold · sheltered from the storm<\/option>/);
assert.match(html, /Next placement: <strong>2 spice<\/strong>/);
assert.match(html, /Your available spice: 6/);
assert.match(html, /Finish Ambassador placement/);
assert.doesNotMatch(markup(projected(false)), /value="hidden_mobile_stronghold"/);
});
void test('placement consumes only authoritative available destinations and never reconstructs missing strongholds', () => {
const view = projected();
view.ambassadors!.destinations = view.ambassadors!.destinations.filter(
(destination) => destination.id === MOBILE_STRONGHOLD,
);
const html = markup(view);
assert.match(html, /value="hidden_mobile_stronghold"/);
assert.doesNotMatch(html, /<option[^>]*value="(?:arrakeen|carthag)"/);
view.ambassadors!.destinations = [];
assert.doesNotMatch(markup(view), /value="hidden_mobile_stronghold"/);
assert.match(markup(view), /No stronghold is currently available for an Ambassador/);
assert.equal(choose(view), undefined);
});
void test('actual placement callback preserves the projected HMS and supply token with the current price', () => {
const view = projected();
const token = view.ambassadors!.tokens.find((candidate) => candidate.zone === 'supply')!;
assert.deepEqual(choose(view, token.id), {
type: 'decision', token: token.id, territory: MOBILE_STRONGHOLD,
});
const selected = renderToStaticMarkup(createElement(EcazPlacementChoice, {
game: view, busy: false, id: 'selected', selectedToken: token.id,
selectedTerritory: MOBILE_STRONGHOLD, onSelectToken() {},
onSelectTerritory() {}, act() {},
}));
assert.match(selected, /value="hidden_mobile_stronghold" selected=""/);
assert.match(selected, /Place Ambassador · 2 spice/);
assert.deepEqual(choose(view, token.id, MOBILE_STRONGHOLD, false, true), {
type: 'decision', decline: true,
});
});
void test('busy, blocked, insufficient spice, stale choices and unavailable supply never dispatch placement', () => {
const view = projected();
const token = view.ambassadors!.tokens.find((candidate) => candidate.zone === 'supply')!.id;
assert.equal(choose(view, token, MOBILE_STRONGHOLD, true), undefined);
assert.equal(choose(view, token, MOBILE_STRONGHOLD, true, true), undefined);
assert.equal(choose(view, ''), undefined);
assert.equal(choose(view, 'stale-token'), undefined);
assert.equal(choose(view, token, ''), undefined);
assert.equal(choose(view, token, 'stale-destination'), undefined);
for (const field of ['blocked', 'spice', 'supply', 'destination'] as const) {
const changed = structuredClone(view);
if (field === 'blocked') changed.ambassadors!.blocked = true;
if (field === 'spice') changed.players[0].spice = 1;
if (field === 'supply') changed.ambassadors!.tokens.find((candidate) => candidate.id === token)!.zone = 'used';
if (field === 'destination') changed.ambassadors!.destinations = [];
assert.equal(choose(changed, token), undefined, field);
assert.deepEqual(choose(changed, token, MOBILE_STRONGHOLD, false, true), {
type: 'decision', decline: true,
}, field);
}
const poor = structuredClone(view);
poor.players[0].spice = 1;
assert.match(markup(poor), /You need 1 more spice for this placement/);
assert.equal((markup(view, true).match(/<(?:select|button)\b[^>]*\sdisabled=""/g) ?? []).length, 4);
});
void test('placement controls retain their actual Ecaz actor and pending decision boundary', () => {
for (const field of ['viewer', 'actor', 'decision', 'ambassadors'] as const) {
const view = projected();
const token = view.ambassadors!.tokens.find((candidate) => candidate.zone === 'supply')!.id;
if (field === 'viewer') view.me = 'i';
if (field === 'actor') view.decision!.player = 'i';
if (field === 'decision') view.decision = null;
if (field === 'ambassadors') view.ambassadors = null;
assert.equal(markup(view), '', field);
assert.equal(choose(view, token), undefined, field);
}
});
void test('public token inspection names an HMS-attached Ambassador and remains read-only for every viewer', () => {
const token: AmbassadorToken = {
id: 'ambassador-emperor', effect: 'emperor', zone: 'placed', location: MOBILE_STRONGHOLD,
};
const html = renderToStaticMarkup(createElement(AmbassadorInspector, { token }));
assert.match(html, /Inspect Emperor Ambassador: Placed in Hidden Mobile Stronghold/);
assert.match(html, /Placed in Hidden Mobile Stronghold/);
assert.doesNotMatch(html, /Placed in a stronghold/);
for (const viewer of ['e', 'i']) {
const view = projected();
view.me = viewer;
view.ambassadors!.tokens = [token];
const supply = renderToStaticMarkup(createElement(AmbassadorSupply, { game: view }));
assert.match(supply, /Placed in Hidden Mobile Stronghold/);
assert.doesNotMatch(supply, /Place Ambassador ·|Finish Ambassador placement/);
}
});
void test('public map marks all placed faction faces at their actual territory, including the static HMS box', () => {
const tokens: AmbassadorToken[] = [
{ id: 'ambassador-emperor', effect: 'emperor', zone: 'placed', location: MOBILE_STRONGHOLD },
{ id: 'ambassador-atreides', effect: 'atreides', zone: 'placed', location: 'arrakeen' },
{ id: 'ambassador-harkonnen', effect: 'harkonnen', zone: 'placed', location: 'carthag' },
{ id: 'ambassador-ecaz', effect: 'ecaz', zone: 'supply', location: null },
{ id: 'ambassador-guild', effect: 'guild', zone: 'pool', location: null },
{ id: 'ambassador-richese', effect: 'richese', zone: 'used', location: null },
{ id: 'ambassador-beneGesserit', effect: 'beneGesserit', zone: 'removed', location: null },
];
const html = renderToStaticMarkup(createElement('svg', null,
createElement(AmbassadorBoardMarkers, { tokens }),
));
assert.equal((html.match(/<g /g) ?? []).length, 3);
for (const token of tokens.filter((candidate) => candidate.zone === 'placed')) {
const identity = faction(token.effect);
const place = territory(token.location!);
const label = `${identity.name} Ambassador in ${place.name}`;
assert.ok(html.includes(`aria-label="${label}"`));
assert.ok(html.includes(`<title>${label}</title>`));
assert.ok(html.includes(`stroke="${identity.color}"`));
assert.ok(html.includes(`>${identity.sigil}</text>`));
}
const hms = territory(MOBILE_STRONGHOLD);
assert.ok(html.includes(`translate(${hms.center[0] + 65},${hms.center[1] + 24})`));
const arrakeen = territory('arrakeen');
assert.ok(html.includes(`translate(${arrakeen.center[0] - 22},${arrakeen.center[1] - 59})`));
assert.doesNotMatch(html, /Ecaz Ambassador|Spacing Guild Ambassador|Richese Ambassador|Bene Gesserit Ambassador|supply|pool|used|removed/);
assert.equal((html.match(/>Amb<\/text>/g) ?? []).length, 3);
assert.equal(renderToStaticMarkup(createElement(AmbassadorBoardMarkers, { tokens: [] })), '');
});
+173
View File
@@ -0,0 +1,173 @@
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);
});
}
+163
View File
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MOBILE_STRONGHOLD as HMS, TERRITORIES } from '../game/board';
import {
blockAmbassadorPlacement,
createAmbassadors,
placeAmbassador,
} from '../game/ecaz-ambassadors';
import {
ambassadorPlacementDestination,
ambassadorPlacementDestinations,
quoteEcazAmbassadorPlacement as quote,
type AmbassadorPlacementBoard,
} from '../game/ecaz-ambassador-placement';
function fixture(): AmbassadorPlacementBoard {
return {
turn: 3,
storm: 18,
mobileStronghold: { location: 'polar_sink:0' },
ecazAmbassadors: createAmbassadors(() => 0),
};
}
function supply(g: AmbassadorPlacementBoard): string[] {
return g.ecazAmbassadors!.tokens
.filter((token) => token.zone === 'supply')
.map((token) => token.id);
}
function rejectUnchanged(
g: AmbassadorPlacementBoard,
token = supply(g)[0],
to = HMS,
spice = 10,
message?: RegExp,
) {
const before = structuredClone(g), owner = { spice };
if (message) assert.throws(() => quote(g, owner, token, to), message);
else assert.throws(() => quote(g, owner, token, to));
assert.deepEqual(g, before);
assert.deepEqual(owner, { spice });
}
void test('a placed HMS is a sheltered Ambassador destination even when its pointer is in the storm', () => {
for (const pointer of ['polar_sink:0', 'arsunt:11']) {
const g = fixture(), owner = { spice: 1 };
g.mobileStronghold = { location: pointer };
g.storm = pointer === 'polar_sink:0' ? 0 : 11;
const before = structuredClone(g), token = supply(g)[0];
const destination = ambassadorPlacementDestination(g, HMS);
assert.deepEqual(destination, {
id: HMS, stronghold: true, inStorm: false, allowed: true,
});
assert.deepEqual(
ambassadorPlacementDestinations(g).find((candidate) => candidate.id === HMS),
{ id: HMS, name: 'Hidden Mobile Stronghold', sheltered: true },
);
const result = quote(g, owner, token, HMS);
assert.deepEqual(result, placeAmbassador(g.ecazAmbassadors!, token, {
turn: g.turn, availableSpice: owner.spice, destination,
}));
assert.equal(result.cost, 1);
assert.equal(result.state.tokens.find((candidate) => candidate.id === token)?.location, HMS);
assert.deepEqual(result.state.placement, { turn: 3, count: 1, blocked: false });
assert.deepEqual(g, before);
assert.deepEqual(owner, { spice: 1 });
}
});
void test('unplaced and malformed HMS pointers never create an Ambassador destination', () => {
const mobileStates: AmbassadorPlacementBoard['mobileStronghold'][] = [
undefined,
null,
{ location: null },
...[
'', 'missing:0', 'arrakeen:10', `${HMS}:0`, 'cistern:0',
'arsunt:0', 'arsunt:11:1', 'polar_sink:', 'polar_sink:00', 'arsunt:NaN',
].map((location) => ({ location })),
];
for (const mobileStronghold of mobileStates) {
const g = fixture();
g.mobileStronghold = mobileStronghold;
assert.deepEqual(ambassadorPlacementDestination(g, HMS), {
id: HMS, stronghold: false, inStorm: true, allowed: false,
});
assert.equal(ambassadorPlacementDestinations(g).some((to) => to.id === HMS), false);
rejectUnchanged(g, undefined, HMS, 10, /eligible stronghold outside the storm/);
}
});
void test('choices retain printed storm legality and admit only the placed HMS among dynamic locations', () => {
const g = fixture();
g.storm = 11;
g.mobileStronghold = { location: 'arsunt:11' };
const before = structuredClone(g), choices = ambassadorPlacementDestinations(g);
assert.deepEqual(choices, [
...TERRITORIES.filter((to) => to.type === 'stronghold' && !to.sectors.includes(g.storm))
.map(({ id, name }) => ({ id, name, sheltered: false })),
{ id: HMS, name: 'Hidden Mobile Stronghold', sheltered: true },
]);
assert.equal(choices.some((to) => to.id === 'carthag'), false);
assert.deepEqual(ambassadorPlacementDestination(g, 'carthag'), {
id: 'carthag', stronghold: true, inStorm: true, allowed: true,
});
rejectUnchanged(g, undefined, 'carthag', 10, /eligible stronghold outside the storm/);
for (const to of ['imperial_basin', 'polar_sink', 'cistern', 'kaitain', 'missing'])
rejectUnchanged(g, undefined, to, 10, /eligible stronghold outside the storm/);
assert.deepEqual(g, before);
});
void test('HMS placement shares ordinary one two three fees without requiring Ecaz forces or control', () => {
const g = fixture(), owner = { spice: 6 }, tokens = supply(g);
for (const [index, to] of ['arrakeen', HMS, 'carthag'].entries()) {
const before = structuredClone(g), balance = owner.spice;
const result = quote(g, owner, tokens[index], to);
assert.equal(result.cost, index + 1);
assert.deepEqual(g, before);
assert.equal(owner.spice, balance);
g.ecazAmbassadors = result.state;
owner.spice -= result.cost;
}
assert.equal(owner.spice, 0);
assert.deepEqual(g.ecazAmbassadors!.placement, { turn: 3, count: 3, blocked: false });
assert.equal(ambassadorPlacementDestinations(g).some((to) => to.id === HMS), false);
rejectUnchanged(g, tokens[3], 'sietch_tabr', 3, /Not enough spice/);
});
void test('one Ambassador of any effect occupies the HMS and an already placed token cannot relocate', () => {
const g = fixture(), tokens = supply(g);
const token = g.ecazAmbassadors!.cohort[0];
g.ecazAmbassadors = quote(g, { spice: 10 }, token, HMS).state;
assert.equal(ambassadorPlacementDestinations(g).some((to) => to.id === HMS), false);
rejectUnchanged(g, tokens.find((id) => id !== token), HMS, 10, /already contains an Ambassador/);
rejectUnchanged(g, token, 'arrakeen', 10, /placed tokens cannot relocate/);
g.ecazAmbassadors = quote(g, { spice: 10 }, tokens.find((id) => id !== token)!, 'arrakeen').state;
assert.equal(ambassadorPlacementDestinations(g).some((to) => to.id === 'arrakeen'), false);
rejectUnchanged(g, tokens[2], 'arrakeen', 10, /already contains an Ambassador/);
});
void test('HMS quotes preserve Karama placement blocking, prior fees and the next-turn reset', () => {
const g = fixture(), tokens = supply(g);
g.ecazAmbassadors = quote(g, { spice: 10 }, tokens[0], 'arrakeen').state;
g.ecazAmbassadors = blockAmbassadorPlacement(g.ecazAmbassadors, g.turn);
assert.deepEqual(g.ecazAmbassadors.placement, { turn: 3, count: 1, blocked: true });
rejectUnchanged(g, tokens[1], HMS, 10, /blocked for this turn/);
g.turn = 2;
rejectUnchanged(g, tokens[1], HMS, 10, /current Ambassador placement turn/);
g.turn = 4;
const before = structuredClone(g), result = quote(g, { spice: 1 }, tokens[1], HMS);
assert.equal(result.cost, 1);
assert.deepEqual(result.state.placement, { turn: 4, count: 1, blocked: false });
assert.deepEqual(g, before);
});
void test('HMS quotes require the original physical supply and an integer spice balance', () => {
const g = fixture();
const pool = g.ecazAmbassadors!.tokens.find((token) => token.zone === 'pool')!;
rejectUnchanged(g, pool.id, HMS, 10, /from your supply/);
rejectUnchanged(g, 'missing-token', HMS, 10, /from your supply/);
for (const spice of [0, -1, 0.5, NaN, Infinity]) rejectUnchanged(g, undefined, HMS, spice);
const noSupply = fixture();
noSupply.ecazAmbassadors = undefined;
rejectUnchanged(noSupply, 'missing-token', HMS, 10, /No Ambassador supply/);
});
+241
View File
@@ -0,0 +1,241 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, TERRITORIES, splitLocation } from '../game/board';
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type EcazHmsAmbassadorStep = { actor: string; action: Action };
export type EcazHmsAmbassadorTransition = {
before: Game; steps: EcazHmsAmbassadorStep[]; after: Game;
};
export const ecazHmsAmbassadorPlayer = (game: Game, id: string) => {
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
};
export const ecazHmsAmbassadorClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export const ecazHmsAmbassadorInventory = (game: Game) => {
const cards = [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
...(game.ixAuction?.cards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots');
return ids;
};
/** Select original scalar lotteries only; native UUIDs and byte entropy remain
* untouched. The nine supplied values are consumed by the original Ecaz packet. */
function originalLottery<T>(run: () => T, rolls: readonly number[] = []): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = cursor < rolls.length ? rolls[cursor++] : 0xffffffff;
else Reflect.apply(original, crypto, [array]);
return array;
};
try {
const result = run(); assert.equal(cursor, rolls.length, 'Original Ambassador packet consumes its nine selected draws');
return result;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actEcazHmsAmbassador(game: Game, actor: string, action: Action,
actions: EcazHmsAmbassadorStep[] = []): Game {
const next = originalLottery(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Only live native windows advance. Placement, entry and later HMS movement
* are caller-owned controls, never installed fixture state. */
export function nextEcazHmsAmbassadorStep(game: Game): EcazHmsAmbassadorStep | null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'ecazPlacement' || game.decision?.kind === 'ecazAmbassador' ||
game.decision?.kind === 'mobileStronghold') return null;
if (game.status === 'setup' && game.setupStage === 'forces' &&
ecazHmsAmbassadorPlayer(game, 'ecaz').reserves === 20)
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
if (game.decision?.kind === 'guildTiming')
return { actor: game.decision.player, action: { type: 'decision', take: false } };
if (game.decision?.kind === 'ixAuction') {
const card = game.ixAuction!.cards[0]; assert.ok(card);
return { actor: game.decision.player, action: { type: 'decision', card: card.id, position: 'bottom' } };
}
}
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceEcazHmsAmbassador(game: Game, until: (state: Game) => boolean,
actions: EcazHmsAmbassadorStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine HMS chronology ended before its requested control');
const next = nextEcazHmsAmbassadorStep(game);
assert.ok(next, `Choose the original control before advancing: ${JSON.stringify({
status: game.status, turn: game.turn, phase: game.phase, active: game.active, decision: game.decision,
})}`);
game = actEcazHmsAmbassador(game, next.actor, next.action, actions);
}
throw Error('The original HMS Ambassador source did not reach its finite boundary');
}
export function passEcazHmsAmbassadorResponses(game: Game,
actions: EcazHmsAmbassadorStep[] = []): Game {
for (let count = 0; game.response && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actEcazHmsAmbassador(game, actor.id, { type: 'passResponse' }, actions);
}
assert.equal(game.response, null); return game;
}
function originalPacket(effect: 'emperor' | 'choam' | 'ixians') {
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = [effect,
...pool.filter(candidate => candidate !== effect).slice(0, 4)];
const desired = [...supply, ...pool.filter(candidate => !supply.includes(candidate))];
const current = [...pool], rolls: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); assert.equal(rolls.length, 9); return { rolls, supply };
}
/** Genuine public bare E1+E3 Basic/Advanced source. Native starting forces,
* private Ixian starting-card choice, first Storm and HMS placement all execute.
* Stop at the actual end-of-Revival opportunity BEFORE Ambassador declaration.
* Only original unopened card/Spice positions and the original packet lottery
* are selected. No army, wallet, alliance, phase, token, receipt or event is set. */
export function createEcazHmsAmbassadorFixture(options: {
advanced?: boolean; effect?: 'emperor' | 'choam' | 'ixians'; inStorm?: boolean; karama?: boolean;
} = {}) {
const advanced = options.advanced ?? false, effect = options.effect ?? 'emperor';
const ecaz = 'ecaz', ix = 'ixians', guild = 'guild';
let game = createGame('ECAZHMSAMBASSADOR', newPlayer(ecaz, 'Ecaz', 'ecaz'), advanced, ['ix', 'ecaz']);
joinGame(game, newPlayer(ix, 'Ixians', 'ixians'));
joinGame(game, newPlayer(guild, 'Guild', 'guild'));
const initial = structuredClone(game), actions: EcazHmsAmbassadorStep[] = [];
const act = (actor: string, action: Action) => { game = actEcazHmsAmbassador(game, actor, action, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = ecazHmsAmbassadorInventory(game);
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 3);
assert.equal(ordinary.length, 3);
const karamaCard = options.karama ? game.deck.find(card => card.effect === 'karama') : null;
if (options.karama) { assert.ok(karamaCard); ordinary[0] = karamaCard; }
const dealOrder = ordinary.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game), packet = originalPacket(effect);
let packetCreation: EcazHmsAmbassadorTransition | null = null;
let privateChoice: EcazHmsAmbassadorTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextEcazHmsAmbassadorStep(game); assert.ok(next);
const before = structuredClone(game);
const initializesForces = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
game.players.filter(player => player.traitorChoices.length > 0).length === 1;
if (initializesForces) {
game = originalLottery(() => applyAction(game, next.actor, next.action), packet.rolls);
actions.push(structuredClone(next));
packetCreation = { before, steps: [structuredClone(next)], after: structuredClone(game) };
} else {
act(next.actor, next.action);
if (before.decision?.kind === 'ixSetup')
privateChoice = { before, steps: [structuredClone(next)], after: structuredClone(game) };
}
}
assert.equal(game.status, 'playing'); assert.ok(packetCreation && privateChoice);
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id =>
game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
assert.equal(ecazHmsAmbassadorPlayer(game, ix).forces[MOBILE_LOCATION], 6);
assert.equal(ecazHmsAmbassadorPlayer(game, ix).elites?.forces[MOBILE_LOCATION], 3);
assert.equal(game.mobileStronghold?.location, null);
assert.ok(!options.karama || ecazHmsAmbassadorPlayer(game, ix).hand.some(card => card.id === karamaCard!.id));
const afterSetup = structuredClone(game), originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1, 2, 3].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
game = advanceEcazHmsAmbassador(game, state => !state.response && !state.phaseOpening &&
state.decision?.kind === 'mobileStronghold' && state.decision.placement, actions);
const point = options.inStorm
? `${TERRITORIES.find(territory => territory.type !== 'stronghold' && territory.sectors.includes(game.storm))!.id}:${game.storm}`
: 'rock_outcroppings:14';
const hmsStep: EcazHmsAmbassadorStep = { actor: ix, action: { type: 'decision', location: point } };
const hmsBefore = structuredClone(game); act(ix, hmsStep.action);
const hmsPlacement: EcazHmsAmbassadorTransition = { before: hmsBefore, steps: [hmsStep], after: structuredClone(game) };
game = advanceEcazHmsAmbassador(game, state => state.phase === 4 && !state.response && !state.phaseOpening &&
state.decision?.kind === 'ecazPlacement', actions);
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === effect)!;
assert.equal(token.zone, 'supply'); assert.equal(token.location, null);
assert.equal(game.ecazAmbassadors!.placement, null);
assert.equal(game.mobileStronghold?.location, point);
assert.equal(ecazHmsAmbassadorPlayer(game, ix).forces[MOBILE_LOCATION], 6);
assert.equal(ecazHmsAmbassadorPlayer(game, ix).elites?.forces[MOBILE_LOCATION], 3);
assert.deepEqual(ecazHmsAmbassadorInventory(game), originalInventory);
const placementStep: EcazHmsAmbassadorStep = { actor: ecaz,
action: { type: 'decision', token: token.id, territory: MOBILE_STRONGHOLD } };
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, ecaz, ix, guild, entrant: guild,
token: token.id, effect, advanced, location: MOBILE_STRONGHOLD, interior: MOBILE_LOCATION,
point, pointer: splitLocation(point), actions, originalInventory, originalSpice, dealOrder, spiceOrder,
packetCreation, privateChoice, hmsPlacement, karama: karamaCard?.id ?? null, placementStep,
placement: placementStep.action, finishPlacement: { type: 'decision', decline: true } satisfies Action,
shipment: { type: 'ship', territory: splitLocation(point).territory, sector: splitLocation(point).sector,
amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
movement: { type: 'move', from: point, territory: MOBILE_STRONGHOLD, sector: 0, amount: 1 } satisfies Action,
endMovement: { type: 'endMovement' } satisfies Action,
staging: [
'Actual public Ecaz/Ixians/Guild ready/start and private Traitor choices. Select only three original unopened Treachery positions, then choose the native first offered Ixian card; the original remainder shuffle deals the other two cards.',
'Select exactly nine original Ecaz packet scalars on the final Traitor choice, before native forces and Ambassador inventory are created. Ecaz is the first seat; Ixians and Guild follow.',
'Select four original unopened ordinary Spice positions. Native first Storm dials, destruction, HMS placement, Spice, Bidding and Revival all execute; no played phase, wallet, army, alliance, token custody, reaction or receipt is assigned.',
'The source stops before original paid Ambassador placement. HMS starts with its native six counters, including three original Cyborgs. Its source pointer comes from the actual first-Storm placement decision.',
],
};
}
export type EcazHmsAmbassadorFixture = ReturnType<typeof createEcazHmsAmbassadorFixture>;
/** Submit the actual source declaration and settle only its live response. */
export function placeEcazHmsAmbassador(fixture: EcazHmsAmbassadorFixture,
actions: EcazHmsAmbassadorStep[] = []): Game {
let game = actEcazHmsAmbassador(fixture.game, fixture.ecaz, fixture.placement, actions);
game = passEcazHmsAmbassadorResponses(game, actions);
assert.equal(game.decision?.kind, 'ecazPlacement');
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === fixture.token)?.location, MOBILE_STRONGHOLD);
return game;
}
/** Start from a committed real placement. Native earlier turns end normally;
* Guild pays for an outside shipment, then really moves into the HMS interior.
* The original Ecaz entry offer remains a caller-owned decision. */
export function enterEcazHmsAmbassador(source: Game, fixture: EcazHmsAmbassadorFixture,
actions: EcazHmsAmbassadorStep[] = []) {
let game = actEcazHmsAmbassador(source, fixture.ecaz, fixture.finishPlacement, actions);
game = advanceEcazHmsAmbassador(game, state => state.phase === 5 && state.active === fixture.guild &&
ecazHmsAmbassadorClean(state), actions);
const beforeShipment = structuredClone(game), shipmentStart = actions.length;
game = actEcazHmsAmbassador(game, fixture.guild, fixture.shipment, actions);
game = advanceEcazHmsAmbassador(game, ecazHmsAmbassadorClean, actions);
const shipment: EcazHmsAmbassadorTransition = { before: beforeShipment,
steps: structuredClone(actions.slice(shipmentStart)), after: structuredClone(game) };
assert.equal(ecazHmsAmbassadorPlayer(game, fixture.guild).forces[fixture.point], 1);
assert.ok(!game.pendingAmbassador);
const beforeMovement = structuredClone(game), movementStart = actions.length;
game = actEcazHmsAmbassador(game, fixture.guild, fixture.movement, actions);
game = advanceEcazHmsAmbassador(game, state => !state.response && !state.phaseOpening &&
state.pendingAmbassador?.stage === 'offer', actions);
const movement: EcazHmsAmbassadorTransition = { before: beforeMovement,
steps: structuredClone(actions.slice(movementStart)), after: structuredClone(game) };
assert.equal(game.pendingAmbassador?.territory, MOBILE_STRONGHOLD);
assert.equal(game.pendingAmbassador.entrant, fixture.guild);
assert.equal(game.pendingAmbassador.arrivalCause, 'movement');
assert.equal(ecazHmsAmbassadorPlayer(game, fixture.guild).forces[MOBILE_LOCATION], 1);
assert.equal(ecazHmsAmbassadorPlayer(game, fixture.guild).forces[fixture.point] ?? 0, 0);
return { game, event: game.pendingAmbassador.event, shipment, movement };
}