Support Basic No-Field Guild Ambassador shipments

Connect native concealment and allied supplier/recipient consent to the independent grant, with explicit provisional cap and transport-fee policy. Preserve actual tokens, ordinary allowances, source-scoped Karama prevention and original income/arrival continuation.

Add genuine three/four-seat sources, focused quote/control/engine cases and bounded runtime/minimal-bot proof. Advanced and optional profiles retain their existing guards.
This commit is contained in:
Quintine committed 2026-10-24 17:36:12 +11:00
1 parent 4b2f4f3fa3
commit 659e2a4a9a
19 files changed
+2221 -56

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type GuildAmbassadorNoFieldStep = { actor: string; action: Action };
export type GuildAmbassadorNoFieldTransition = {
before: Game; steps: GuildAmbassadorNoFieldStep[]; after: Game;
};
export const guildAmbassadorNoFieldPlayer = (game: Game, id: string) => {
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
};
export const guildAmbassadorNoFieldClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export const guildAmbassadorNoFieldInventory = (game: Game) => {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(player => player.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original physical Treachery Card has one current slot');
return ids;
};
/** Select only original scalar lotteries. Nine selected draws belong to the
* original Ambassador packet; UUIDs and other byte entropy remain native. */
function withOriginalScalarLottery<T>(run: () => T, packet: 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 < packet.length ? packet[cursor++] : 0xffffffff;
else Reflect.apply(original, crypto, [array]);
return array;
};
try {
const result = run(); assert.equal(cursor, packet.length, 'The original packet consumes its nine selected draws');
return result;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actGuildAmbassadorNoField(game: Game, actor: string, action: Action,
actions: GuildAmbassadorNoFieldStep[] = []): Game {
const next = withOriginalScalarLottery(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
function nextStep(game: Game): GuildAmbassadorNoFieldStep | null {
if (!game.response && !game.phaseOpening) {
if (game.decision?.kind === 'ecazAmbassador' || game.decision?.kind === 'ecazPlacement') return null;
if (game.status === 'setup' && game.setupStage === 'forces' &&
guildAmbassadorNoFieldPlayer(game, 'ecaz').reserves === 20)
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
if (game.decision?.kind === 'richeseCache') return {
actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
card: game.richeseCache![0].id, method: 'onceAround', direction: 'counterclockwise' },
};
if (game.decision?.kind === 'richeseUnbid') return { actor: game.decision.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
}
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceGuildAmbassadorNoField(game: Game, until: (state: Game) => boolean,
actions: GuildAmbassadorNoFieldStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Basic source ended before its next control');
const next = nextStep(game);
assert.ok(next, `Choose the original Ambassador control before advancing: ${JSON.stringify({
status: game.status, phase: game.phase, active: game.active, decision: game.decision,
entry: game.pendingAmbassador && { stage: game.pendingAmbassador.stage, effect: game.pendingAmbassador.effect },
})}`);
game = actGuildAmbassadorNoField(game, next.actor, next.action, actions);
}
throw Error('The original Guild Ambassador source did not reach its finite boundary');
}
export function passGuildAmbassadorNoFieldResponses(game: Game,
actions: GuildAmbassadorNoFieldStep[] = []): 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 = actGuildAmbassadorNoField(game, actor.id, { type: 'passResponse' }, actions);
}
assert.equal(game.response, null); return game;
}
function originalGuildPacket() {
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['guild', 'atreides', 'emperor', 'fremen', 'harkonnen'];
const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))];
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 };
}
/** Fresh public bare Basic E2/E3 source. Ecaz pays for both original markers.
* Its reusable self-marker really creates the Richese alliance at Carthag;
* Richese explicitly accepts and Ecaz declines the optional Basic Duke loan.
* Both future beneficiaries already made their own paid ordinary shipments and
* ended their movement turns before Harkonnen pays for the Guild-marker entry.
* Only original undealt card/packet order is selected. No wallet, army, phase,
* alliance, allowance, token custody or continuation is assigned. */
export function createGuildAmbassadorNoFieldFixture(options: {
beneficiary: 'own' | 'allied'; priorMarker?: boolean; karama?: boolean; includeGuild?: boolean;
}) {
let game = createGame('GUILDAMBASSADORNOFIELD', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
const includeGuild = options.includeGuild !== false;
const roster = includeGuild ? ['richese', 'harkonnen', 'guild'] as const : ['richese', 'harkonnen'] as const;
for (const faction of roster)
joinGame(game, newPlayer(faction, faction, faction));
const ownerEcaz = 'ecaz', richese = 'richese', entrantHark = 'harkonnen', guild = 'guild';
const beneficiaryID = options.beneficiary === 'own' ? richese : ownerEcaz;
const actions: GuildAmbassadorNoFieldStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => { game = actGuildAmbassadorNoField(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceGuildAmbassadorNoField(game, until, actions); };
const transition = (run: () => void): GuildAmbassadorNoFieldTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
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 = guildAmbassadorNoFieldInventory(game);
const originalMainDeck = game.deck.map(card => card.id).sort();
const originalCache = game.richeseCache!.map(card => card.id).sort();
// Harkonnen receives two actual starting cards; all other seats receive one.
const dealCount = game.players.length + 1;
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, dealCount);
assert.equal(ordinary.length, dealCount);
const heldKarama = options.karama ? game.deck.find(card => card.effect === 'karama') : null;
if (options.karama) { assert.ok(heldKarama); ordinary[2] = heldKarama; }
const deal = ordinary.map(card => card.id);
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game), packet = originalGuildPacket();
let packetCreation: GuildAmbassadorNoFieldTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextStep(game); assert.ok(next);
const initializesForces = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
game.players.filter(player => player.traitorChoices.length > 0).length === 1;
if (initializesForces) {
const before = structuredClone(game);
game = withOriginalScalarLottery(() => 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);
}
assert.equal(game.status, 'playing'); assert.ok(packetCreation);
const afterSetup = structuredClone(game);
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id =>
game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].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);
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const selfToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
const guildToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'guild')!;
const placements = transition(() => {
act(ownerEcaz, { type: 'decision', token: selfToken.id, territory: 'carthag' });
reach(state => !state.response && state.decision?.kind === 'ecazPlacement');
act(ownerEcaz, { type: 'decision', token: guildToken.id, territory: 'habbanya_ridge_sietch' });
reach(state => !state.response && state.decision?.kind === 'ecazPlacement');
act(ownerEcaz, { type: 'decision', decline: true });
});
assert.equal(guildAmbassadorNoFieldPlayer(game, ownerEcaz).spice,
guildAmbassadorNoFieldPlayer(placements.before, ownerEcaz).spice - 3);
reach(state => state.phase === 5 && state.active === ownerEcaz && guildAmbassadorNoFieldClean(state));
assert.deepEqual(game.order, [ownerEcaz, richese, entrantHark, ...(includeGuild ? [guild] : [])]);
assert.equal(game.storm, 1);
const ordinaryEcaz = transition(() => {
act(ownerEcaz, { type: 'ship', amount: 1, territory: 'polar_sink', sector: 0, elite: 0, allyPayment: 0 });
reach(guildAmbassadorNoFieldClean); act(ownerEcaz, { type: 'endMovement' });
reach(state => state.active === richese && guildAmbassadorNoFieldClean(state));
});
const tokens = guildAmbassadorNoFieldPlayer(game, richese).noField!.tokens.map(token => ({ ...token }));
const priorToken = tokens.find(token => token.value === 3)!;
const alliance = transition(() => {
const richeseEvent = guildAmbassadorNoFieldPlayer(game, richese).noFieldEvent!;
act(richese, options.priorMarker
? { type: 'ship', noField: priorToken.id, event: richeseEvent, territory: 'carthag', sector: 11 }
: { type: 'ship', amount: 1, territory: 'carthag', sector: 11, elite: 0, allyPayment: 0 });
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
act(ownerEcaz, { type: 'decision', event: game.pendingAmbassador!.event,
trigger: true, beneficiary: ownerEcaz, choice: 'alliance' });
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
act(richese, { type: 'decision', event: game.pendingAmbassador!.event, accept: true });
assert.equal(game.pendingAmbassador?.stage, 'loan');
act(ownerEcaz, { type: 'decision', event: game.pendingAmbassador!.event, loan: false });
reach(guildAmbassadorNoFieldClean);
});
assert.equal(guildAmbassadorNoFieldPlayer(game, ownerEcaz).ally, richese);
assert.equal(guildAmbassadorNoFieldPlayer(game, richese).ally, ownerEcaz);
act(richese, { type: 'endMovement' });
reach(state => state.active === entrantHark && guildAmbassadorNoFieldClean(state));
const arrival = transition(() => {
act(entrantHark, { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: 17,
elite: 0, allyPayment: 0 });
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
});
assert.equal(guildAmbassadorNoFieldPlayer(game, entrantHark).spice,
guildAmbassadorNoFieldPlayer(arrival.before, entrantHark).spice - 1);
const beforeTrigger = structuredClone(game);
act(ownerEcaz, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: beneficiaryID });
assert.ok(game.pendingAmbassador); assert.equal(game.pendingAmbassador.stage, 'ship');
assert.equal(game.pendingAmbassador.effect, 'guild');
assert.equal(game.decision?.player, beneficiaryID);
assert.equal(game.active, entrantHark);
assert.ok(guildAmbassadorNoFieldPlayer(game, ownerEcaz).shipped);
assert.ok(guildAmbassadorNoFieldPlayer(game, richese).shipped);
assert.deepEqual(guildAmbassadorNoFieldInventory(game), originalInventory);
const event = game.pendingAmbassador.event;
return { game, event, ownerEcaz, richese, beneficiaryID, entrantHark, guild, includeGuild, tokens,
territory: 'sietch_tabr', sector: 14, desert: 'habbanya_ridge_flat', desertSector: 17,
priorToken, currentTokenEvent: guildAmbassadorNoFieldPlayer(game, richese).noFieldEvent!,
karama: heldKarama?.id ?? null, actions, staging, lobby, setup, beforeStartingDeal, afterSetup,
originalInventory, originalMainDeck, originalCache, originalSpice, dealOrder, spiceOrder,
packetCreation, placements, ordinaryEcaz, alliance, arrival, beforeTrigger };
}
export type GuildAmbassadorNoFieldFixture = ReturnType<typeof createGuildAmbassadorNoFieldFixture>;
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
actGuildAmbassadorNoField as act, advanceGuildAmbassadorNoField as advance,
createGuildAmbassadorNoFieldFixture as fixture, guildAmbassadorNoFieldClean as clean,
guildAmbassadorNoFieldInventory as inventory, guildAmbassadorNoFieldPlayer as player,
passGuildAmbassadorNoFieldResponses as passResponses,
} from './fixture-guild-ambassador-no-field';
void test('genuine three-seat allied Guild Ambassador shipment pays its fixed stronghold fee to the bank when Guild is absent', () => {
const f = fixture({ beneficiary: 'allied', includeGuild: false, karama: true });
const before = structuredClone(f.game), totalSpice = before.players.reduce((sum, p) => sum + p.spice, 0);
const beforeAllowances = { active: before.active, remaining: before.movementRemaining,
players: before.players.map(p => ({ id: p.id, shipped: p.shipped, moved: p.moved })) };
assert.equal(f.includeGuild, false); assert.equal(before.players.length, 3);
assert.ok(before.players.every(p => p.faction !== 'guild'));
assert.deepEqual(before.players.map(p => p.faction), ['ecaz', 'richese', 'harkonnen']);
assert.deepEqual(before.order, [f.ownerEcaz, f.richese, f.entrantHark]);
assert.deepEqual(f.staging, []);
assert.equal(f.dealOrder.length, 4, 'The original three-seat deal gives Harkonnen its actual second card');
let game = act(f.game, f.beneficiaryID, { type: 'decision', event: f.event, requestNoField: true,
territory: f.territory, sector: f.sector });
assert.equal(game.decision?.player, f.richese);
game = act(game, f.richese, { type: 'decision', event: f.event,
offerNoField: f.tokens.find(token => token.value === 3)!.id,
noFieldEvent: player(game, f.richese).noFieldEvent, payer: f.richese, elite: 0 });
assert.equal(game.decision?.player, f.beneficiaryID);
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
assert.equal(game.response?.kind, 'richeseNoField');
for (const p of game.players) {
assert.equal(p.spice, player(before, p.id).spice);
assert.equal(p.reserves, player(before, p.id).reserves);
assert.deepEqual(p.forces, player(before, p.id).forces);
}
game = passResponses(game); game = advance(game, clean);
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 3);
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 3);
assert.equal(player(game, f.richese).noField!.deployed, null);
assert.equal(player(game, f.richese).noField!.lastShipped, f.tokens.find(token => token.value === 3)!.id);
assert.equal(player(game, f.richese).spice, player(before, f.richese).spice - 1);
assert.equal(player(game, f.beneficiaryID).spice, player(before, f.beneficiaryID).spice);
assert.equal(player(game, f.entrantHark).spice, player(before, f.entrantHark).spice);
assert.equal(game.players.reduce((sum, p) => sum + p.spice, 0), totalSpice - 1);
assert.deepEqual({ active: game.active, remaining: game.movementRemaining,
players: game.players.map(p => ({ id: p.id, shipped: p.shipped, moved: p.moved })) }, beforeAllowances);
assert.equal(game.pendingAmbassador, null);
game = act(game, f.entrantHark, { type: 'move', from: 'habbanya_ridge_sietch:17', amount: 1,
territory: f.desert, sector: f.desertSector });
game = advance(game, clean);
assert.equal(player(game, f.entrantHark).forces[`${f.desert}:${f.desertSector}`], 1);
game = act(game, f.entrantHark, { type: 'endMovement' });
assert.ok(!game.movementRemaining?.includes(f.entrantHark));
assert.deepEqual(inventory(game), f.originalInventory);
});
@@ -0,0 +1,250 @@
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 {
AmbassadorNoField,
AmbassadorNoFieldChoice,
type AmbassadorNoFieldOffer,
} from '../components/ambassador-no-field';
import { AmbassadorShipment } from '../components/ambassador-shipment';
import type { Action, GameView } from '../game/engine';
const event = 'guild-ambassador-no-field-original';
function option(
key: string,
fields: Partial<AmbassadorNoFieldOffer['options'][number]> = {},
): AmbassadorNoFieldOffer['options'][number] {
return {
key,
label: `Concealed No-Field ${key} · Arrakeen sector 10 · free`,
territory: 'arrakeen',
sector: 10,
action: {
type: 'decision', event, amount: 1, noField: `richese-${key}`,
territory: 'arrakeen', sector: 10,
},
blocked: null,
...fields,
};
}
function offer(stage: AmbassadorNoFieldOffer['stage'] = 'own'): AmbassadorNoFieldOffer {
return {
stage,
policy: 'Provisional Guild Ambassador No-Field policy: a concealed native marker counts as one force and costs no spice. This independent grant does not spend ordinary shipment or movement.',
options: [0, 3, 5].map((value) => option(String(value))),
};
}
// These component contracts deliberately use projected offers. The related
// engine source fixtures exercise actual token custody, payment and continuation.
function projected(noField: AmbassadorNoFieldOffer | null = offer()): GameView {
return {
me: 'r',
players: [
{ id: 'r', name: 'Richese', faction: 'richese', reserves: 10 },
{ id: 'e', name: 'Ecaz', faction: 'ecaz', reserves: 10 },
],
decision: { kind: 'ecazAmbassador', player: 'r', event },
ambassadorEntry: {
event, stage: 'ship', effect: 'guild', beneficiary: 'r', noField,
shipment: {
maximum: 4, eliteReserves: 0,
destinations: [{ territory: 'arrakeen', sector: 10, maximum: 4, blocked: null }],
},
},
} as unknown as GameView;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(AmbassadorShipment, {
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(
current: AmbassadorNoFieldOffer,
selectedKey: string,
busy = false,
decline = false,
): Action | undefined {
let action: Action | undefined;
const tree = AmbassadorNoFieldChoice({
offer: current, event, selectedKey, busy,
onSelect() {}, act(value) { action = value; },
});
click(tree, decline ? 'ambassador-no-field-decline' : 'ambassador-no-field-submit');
return action;
}
void test('native marker controls retain the separate physical grant and submit only the complete projected token action', () => {
const current = offer();
const html = markup(projected(current));
assert.match(html, /aria-label="Ambassador No-Field shipment"/);
assert.match(html, /Provisional Guild Ambassador No-Field policy/);
assert.match(html, /does not spend ordinary shipment or movement/);
for (const value of [0, 3, 5]) assert.match(html, new RegExp(`Concealed No-Field ${value}`));
assert.match(html, /Selected: 4 physical forces, including 0 elites/);
assert.match(html, /Ship selected forces for free/);
assert.match(html, /Send no forces/);
assert.doesNotMatch(html, /No-Field substitution.*unfinished/);
for (const selected of current.options) {
const action = choose(current, selected.key);
assert.deepEqual(action, selected.action);
assert.equal(action?.amount, 1);
assert.equal(action?.elite, undefined);
assert.equal(action?.smuggler, undefined);
assert.equal(action?.type, 'decision');
}
});
void test('zero reserves still expose marker destinations without inventing physical forces', () => {
const game = projected();
game.players[0].reserves = 0;
game.ambassadorEntry!.shipment!.maximum = 0;
game.ambassadorEntry!.shipment!.destinations = [];
const html = markup(game);
assert.match(html, /value="arrakeen" selected=""/);
assert.match(html, /value="arrakeen:10" selected=""/);
assert.match(html, /Concealed No-Field 0/);
assert.match(html, /Selected: 0 physical forces, including 0 elites/);
assert.match(html, /You have no reserve forces available for this shipment/);
assert.match(html, /Send no forces/);
});
void test('an existing native marker can be revealed without choosing or carrying a destination', () => {
const current = offer();
current.options = [option('reveal', {
label: 'Reveal existing No-Field', territory: undefined, sector: undefined,
action: { type: 'decision', event, revealNoField: true },
})];
const selection = AmbassadorNoField({
game: projected(current), busy: false, act() {}, destination: 'hagga_basin', sector: 12,
});
assert.ok(selection);
assert.equal(selection.props.offer.options.length, 1);
assert.deepEqual(choose(current, 'reveal'), current.options[0].action);
});
void test('allied request hides token values and both private decision stages replace the physical form', () => {
const request = offer('request');
request.policy = 'Request a whole-grant No-Field substitution from your actual Richese ally. The ally chooses its private marker before you consent.';
request.options = [option('request-arrakeen', {
label: 'Ask Richese for a No-Field in Arrakeen sector 10',
action: { type: 'decision', event, territory: 'arrakeen', sector: 10, requestNoField: true },
})];
const requestGame = projected(request);
requestGame.me = 'e';
requestGame.decision!.player = 'e';
requestGame.ambassadorEntry!.beneficiary = 'e';
const requestHtml = markup(requestGame);
assert.match(requestHtml, /Request an allied No-Field/);
assert.match(requestHtml, /Ask Richese for a No-Field in Arrakeen/);
assert.doesNotMatch(requestHtml, /No-Field [035]/);
assert.match(requestHtml, /Ship selected forces for free/);
assert.deepEqual(choose(request, request.options[0].key), request.options[0].action);
for (const stage of ['supply', 'consent'] as const) {
const current = offer(stage);
current.policy = 'The allied marker reveals immediately: 3 reserve forces, capped at four. Mandatory transport fee: 1 spice to the Guild or bank, paid by either ally; this does not spend ordinary shipment or movement.';
current.options = stage === 'supply'
? [0, 3, 5].flatMap((value) => ['r', 'e'].map((payer) => option(`${stage}-${value}-${payer}`, {
label: `Offer No-Field ${value} · ${Math.min(value, 4)} forces · ${payer === 'r' ? 'Richese' : 'Ecaz'} pays 1 spice`,
action: { type: 'decision', event, choice: stage, token: `richese-${value}`, payer },
})))
: [option(`${stage}-3`, {
label: 'Accept No-Field 3 · 3 forces · Richese pays 1 spice',
action: { type: 'decision', event, choice: stage, token: 'richese-3', payer: 'r' },
})];
current.decline = { type: 'decision', event, declineNoField: true };
current.declineLabel = 'Decline and return to the original grant';
const game = projected(current);
game.ambassadorEntry!.beneficiary = 'e';
game.ambassadorEntry!.shipment = null;
if (stage === 'consent') {
game.me = 'e';
game.decision!.player = 'e';
}
const html = markup(game);
assert.match(html, /aria-label="Ambassador No-Field shipment"/);
assert.match(html, /No-Field 3 · 3 forces · Richese pays 1 spice/);
if (stage === 'supply') {
assert.match(html, /No-Field 0 · 0 forces · Ecaz pays 1 spice/);
assert.match(html, /No-Field 5 · 4 forces · Ecaz pays 1 spice/);
}
assert.match(html, /Mandatory transport fee: 1 spice to the Guild or bank/);
assert.match(html, /Decline and return to the original grant/);
assert.doesNotMatch(html, /Physical forces to ship|Ship selected forces for free|Send no forces/);
for (const selected of current.options)
assert.deepEqual(choose(current, selected.key), selected.action);
assert.deepEqual(choose(current, '', false, true), current.decline);
}
});
void test('busy, unselected, blocked and obsolete choices cannot dispatch through their callbacks', () => {
const current = offer('supply');
current.options.push(option('blocked', { blocked: 'The payer cannot cover this transport fee.' }));
current.decline = { type: 'decision', event, declineNoField: true };
for (const selected of ['', 'obsolete', 'blocked']) assert.equal(choose(current, selected), undefined);
assert.equal(choose(current, '3', true), undefined);
assert.equal(choose(current, '', true, true), undefined);
const html = renderToStaticMarkup(createElement(AmbassadorNoFieldChoice, {
offer: current, event, selectedKey: '3', onSelect() {}, act() {}, busy: true,
}));
assert.equal((html.match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2);
assert.match(html, /The payer cannot cover this transport fee/);
assert.equal(choose({ ...current, options: [] }, '3'), undefined);
});
void test('fresh event, stage and destination projections reset selection and use only the incoming authoritative action', () => {
const game = projected();
const first = AmbassadorNoField({ game, busy: false, act() {}, destination: 'arrakeen', sector: 10 });
assert.ok(first);
const next = structuredClone(game);
next.ambassadorEntry!.event = 'guild-ambassador-no-field-next';
next.ambassadorEntry!.noField!.options[1].action = { type: 'decision', event: 'guild-ambassador-no-field-next', amount: 1, noField: 'new-3', territory: 'arrakeen', sector: 10 };
const fresh = AmbassadorNoField({ game: next, busy: false, act() {}, destination: 'arrakeen', sector: 10 });
assert.ok(fresh);
assert.notEqual(first.key, fresh.key);
assert.deepEqual(choose(fresh.props.offer, '3'), next.ambassadorEntry!.noField!.options[1].action);
next.ambassadorEntry!.noField!.stage = 'supply';
const stage = AmbassadorNoField({ game: next, busy: false, act() {} });
assert.ok(stage);
assert.notEqual(fresh.key, stage.key);
const otherDestination = AmbassadorNoField({ game, busy: false, act() {}, destination: 'hagga_basin', sector: 12 });
assert.ok(otherDestination);
assert.notEqual(first.key, otherDestination.key);
assert.equal(otherDestination.props.offer.options.length, 0);
assert.equal(choose(otherDestination.props.offer, '3'), undefined);
});
void test('only the current Ambassador decision actor gets controls, even if an obsolete projection remains', () => {
const game = projected();
game.decision!.player = 'e';
assert.equal(markup(game), '');
assert.equal(AmbassadorNoField({ game, busy: false, act() {} }), null);
const ended = projected();
ended.ambassadorEntry!.stage = 'move';
assert.equal(markup(ended), '');
const noOffer = projected(null);
assert.doesNotMatch(markup(noOffer), /Ambassador No-Field shipment/);
assert.match(markup(noOffer), /Send no forces/);
});
@@ -0,0 +1,317 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actGuildAmbassadorNoField as act, advanceGuildAmbassadorNoField as advance,
createGuildAmbassadorNoFieldFixture as fixture, guildAmbassadorNoFieldClean as clean,
guildAmbassadorNoFieldInventory as inventory, guildAmbassadorNoFieldPlayer as player,
passGuildAmbassadorNoFieldResponses as passResponses,
type GuildAmbassadorNoFieldFixture,
} from './fixture-guild-ambassador-no-field';
function allowances(game: Game) {
return { active: game.active, order: game.order, remaining: game.movementRemaining,
players: game.players.map(p => ({ id: p.id, shipped: p.shipped, moved: p.moved })) };
}
function material(game: Game) {
return game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, forces: p.forces,
noField: p.noField, noFieldEvent: p.noFieldEvent, noFieldBlockedTurn: p.noFieldBlockedTurn }));
}
function nativeAction(f: GuildAmbassadorNoFieldFixture, value: 0 | 3 | 5, extra: Partial<Action> = {}): Action {
const token = f.tokens.find(token => token.value === value)!;
return { type: 'decision', event: f.event, amount: 1, noField: token.id,
noFieldEvent: player(f.game, f.richese).noFieldEvent,
territory: f.territory, sector: f.sector, ...extra };
}
function reject(game: Game, actor: string, action: Action, reason?: RegExp) {
const before = structuredClone(game), submitted = structuredClone(action);
if (reason) assert.throws(() => act(game, actor, action), reason);
else assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before); assert.deepEqual(action, submitted);
}
function finishEntrant(f: GuildAmbassadorNoFieldFixture, game: Game) {
game = advance(game, clean);
assert.equal(game.pendingAmbassador, null);
assert.equal(game.active, f.entrantHark);
assert.equal(player(game, f.entrantHark).forces['habbanya_ridge_sietch:17'], 1);
game = act(game, f.entrantHark, { type: 'move', from: 'habbanya_ridge_sietch:17', amount: 1,
territory: f.desert, sector: f.desertSector });
game = advance(game, clean);
assert.equal(player(game, f.entrantHark).forces[`${f.desert}:${f.desertSector}`], 1);
game = act(game, f.entrantHark, { type: 'endMovement' });
game = advance(game, state => state.active === f.guild && clean(state));
assert.deepEqual(inventory(game), f.originalInventory);
return game;
}
void test('genuine Basic Guild marker source uses native deal, paid placements, accepted Ecaz alliance and paid entrant', () => {
for (const beneficiary of ['own', 'allied'] as const) {
const f = fixture({ beneficiary }), g = f.game;
assert.deepEqual(f.staging, []);
assert.equal(g.advanced, false); assert.deepEqual(g.expansions, ['ecaz', 'choam']);
assert.ok(!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
!g.strongholdCards && !g.discoveryEnabled && !g.ecazTreachery);
assert.equal(g.status, 'playing'); assert.equal(g.turn, 1); assert.equal(g.phase, 5);
assert.ok(g.pendingAmbassador); assert.equal(g.pendingAmbassador.stage, 'ship');
assert.equal(g.pendingAmbassador.effect, 'guild');
assert.equal(g.pendingAmbassador.entrant, f.entrantHark);
assert.equal(g.pendingAmbassador.beneficiary, f.beneficiaryID);
assert.equal(g.decision?.player, f.beneficiaryID);
assert.deepEqual(g.order, [f.ownerEcaz, f.richese, f.entrantHark, f.guild]);
assert.equal(player(g, f.ownerEcaz).ally, f.richese); assert.equal(player(g, f.richese).ally, f.ownerEcaz);
assert.equal(player(g, f.ownerEcaz).shipped, true); assert.equal(player(g, f.richese).shipped, true);
assert.equal(player(g, f.entrantHark).shipped, true); assert.equal(player(g, f.guild).shipped, false);
assert.equal(player(g, f.richese).forces['carthag:11'], 1);
assert.equal(player(g, f.ownerEcaz).forces['polar_sink:0'], 1);
assert.equal(player(g, f.entrantHark).forces['habbanya_ridge_sietch:17'], 1);
assert.equal(g.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!.zone, 'supply');
assert.equal(g.ecazAmbassadors!.tokens.find(token => token.effect === 'guild')!.zone, 'used');
assert.ok(f.alliance.steps.some(step => step.actor === f.richese && step.action.accept === true));
assert.ok(f.alliance.steps.some(step => step.actor === f.ownerEcaz && step.action.loan === false));
assert.deepEqual(inventory(g), f.originalInventory);
}
});
for (const value of [0, 3, 5] as const) {
void test(`native Basic Richese Guild grant conceals original ${value} for free without spending its ordinary allowance`, () => {
const f = fixture({ beneficiary: 'own', karama: true }), before = structuredClone(f.game);
const token = f.tokens.find(token => token.value === value)!;
let game = act(f.game, f.richese, nativeAction(f, value));
assert.equal(game.response?.kind, 'richeseNoField');
assert.deepEqual(material(game), material(before));
game = passResponses(game);
assert.deepEqual(player(game, f.richese).noField!.deployed, {
tokenId: token.id, controller: f.richese, location: { territory: f.territory, sector: f.sector },
});
assert.equal(player(game, f.richese).noField!.lastShipped, token.id);
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves);
assert.equal(player(game, f.richese).forces[`${f.territory}:${f.sector}`] ?? 0, 0);
for (const p of game.players) assert.equal(p.spice, player(before, p.id).spice);
assert.deepEqual(allowances(game), allowances(before));
const publicMarker = viewGame(game, f.entrantHark).players.find(p => p.id === f.richese)!.noField!.deployed!;
assert.equal(publicMarker.effectiveForces, 1);
assert.deepEqual(inventory(game), f.originalInventory);
finishEntrant(f, game);
});
}
function requestAllied(f: GuildAmbassadorNoFieldFixture, game = f.game, desert = false) {
return act(game, f.beneficiaryID, { type: 'decision', event: f.event, requestNoField: true,
territory: desert ? f.desert : f.territory, sector: desert ? f.desertSector : f.sector });
}
function offerAllied(f: GuildAmbassadorNoFieldFixture, game: Game, value: 0 | 3 | 5,
payer = f.richese) {
return act(game, f.richese, { type: 'decision', event: f.event,
offerNoField: f.tokens.find(token => token.value === value)!.id,
noFieldEvent: player(game, f.richese).noFieldEvent, payer, elite: 0 });
}
void test('a five-marker materializes four available allied counters without inventing the missing fifth', () => {
const f = fixture({ beneficiary: 'allied' });
// Controlled prior reserve shortage after the genuine grant: move existing
// Ecaz counters to its original setup location, without changing total custody.
const source = structuredClone(f.game), recipient = player(source,f.beneficiaryID);
recipient.forces['imperial_basin:9'] += recipient.reserves - 4;
recipient.reserves = 4;
const before = structuredClone(source);
let game = requestAllied(f,source);
game = offerAllied(f,game,5,f.richese);
game = act(game,f.beneficiaryID,{type:'decision',event:f.event,acceptNoField:true});
game = passResponses(game);
assert.equal(player(game,f.beneficiaryID).reserves,0);
assert.equal(player(game,f.beneficiaryID).forces[`${f.territory}:${f.sector}`],4);
assert.equal(player(game,f.richese).noField!.lastShipped,f.tokens.find(token => token.value === 5)!.id);
assert.deepEqual(allowances(game),allowances(before));
for (const p of game.players) assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum,n) => sum+n,0),20);
finishEntrant(f,game);
});
void test('native No-Field remains available when the physical four-force grant has no reserve counter', () => {
const f = fixture({ beneficiary: 'own' });
// Controlled prior deployment conserves all original counters; the new
// concealed marker itself withdraws none and may later reveal zero.
const source = structuredClone(f.game), richese = player(source,f.richese);
richese.forces['carthag:11'] += richese.reserves;
richese.reserves = 0;
const view = viewGame(source,f.richese);
assert.equal(view.ambassadorEntry?.shipment?.maximum,0);
assert.ok(view.ambassadorEntry?.noField?.options.some(option => option.action.noField === f.tokens.find(token => token.value === 5)!.id));
let game = act(source,f.richese,nativeAction(f,5));
game = passResponses(game);
assert.equal(player(game,f.richese).reserves,0);
assert.equal(player(game,f.richese).forces['carthag:11'],20);
assert.equal(player(game,f.richese).noField!.deployed!.tokenId,f.tokens.find(token => token.value === 5)!.id);
finishEntrant(f,game);
});
void test('original ordinary starting hands automatically allow both Guild marker declarations without a pending Karama responder', () => {
for (const beneficiary of ['own', 'allied'] as const) {
const f = fixture({ beneficiary }), before = structuredClone(f.game);
assert.ok(f.game.players.every(p => p.hand.every(card => card.effect !== 'karama')));
let game = beneficiary === 'own' ? act(f.game, f.richese, nativeAction(f, 3)) : requestAllied(f);
if (beneficiary === 'allied') {
game = offerAllied(f, game, 3, f.beneficiaryID);
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 3);
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 3);
assert.equal(player(game, f.richese).noField!.deployed, null);
} else {
assert.equal(player(game, f.richese).noField!.deployed!.tokenId, f.tokens.find(token => token.value === 3)!.id);
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves);
}
assert.equal(game.response, null);
assert.deepEqual(allowances(game), allowances(before));
finishEntrant(f, game);
}
});
for (const desert of [false, true]) {
for (const payee of ['supplier', 'recipient', ...(desert ? ['both'] : [])]) {
void test(`allied Basic three-marker ${desert ? 'desert' : 'stronghold'} grant charges the printed fee to ${payee}`, () => {
const f = fixture({ beneficiary: 'allied', karama: true }), before = structuredClone(f.game);
const payer = payee === 'supplier' ? f.richese : payee === 'recipient' ? f.beneficiaryID : 'both';
let game = requestAllied(f, f.game, desert);
assert.equal(game.decision?.player, f.richese);
assert.deepEqual(material(game), material(before));
game = offerAllied(f, game, 3, payer);
assert.equal(game.decision?.player, f.beneficiaryID);
assert.deepEqual(material(game), material(before));
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
assert.equal(game.response?.kind, 'richeseNoField');
assert.deepEqual(material(game), material(before));
game = passResponses(game);
const cost = desert ? 2 : 1, ownerPayment = payee === 'supplier' ? cost : payee === 'both' ? 1 : 0;
const recipientPayment = cost - ownerPayment;
const target = desert ? `${f.desert}:${f.desertSector}` : `${f.territory}:${f.sector}`;
assert.equal(player(game, f.beneficiaryID).forces[target], 3);
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 3);
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves);
assert.equal(player(game, f.richese).noField!.deployed, null);
assert.equal(player(game, f.richese).noField!.lastShipped, f.tokens.find(token => token.value === 3)!.id);
assert.equal(player(game, f.richese).spice, player(before, f.richese).spice - ownerPayment);
assert.equal(player(game, f.beneficiaryID).spice, player(before, f.beneficiaryID).spice - recipientPayment);
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + cost);
assert.deepEqual(allowances(game), allowances(before));
assert.deepEqual(inventory(game), f.originalInventory);
finishEntrant(f, game);
});
}
}
void test('actual prior native three-marker must be explicitly revealed before a different Guild marker is declared', () => {
const f = fixture({ beneficiary: 'own', priorMarker: true });
const original = structuredClone(f.game), richese = player(original, f.richese);
assert.deepEqual(richese.noField!.deployed, {
tokenId: f.priorToken.id, controller: f.richese, location: { territory: 'carthag', sector: 11 },
});
reject(f.game, f.richese, nativeAction(f, 5), /reveal/i);
let game = act(f.game, f.richese, { type: 'decision', event: f.event, revealNoField: true });
assert.equal(game.pendingAmbassador?.stage, 'ship'); assert.equal(game.decision?.player, f.richese);
assert.equal(player(game, f.richese).noField!.deployed, null);
assert.equal(player(game, f.richese).forces['carthag:11'], 3);
assert.equal(player(game, f.richese).reserves, richese.reserves - 3);
assert.equal(player(game, f.richese).noField!.lastShipped, f.priorToken.id);
assert.notEqual(player(game, f.richese).noFieldEvent, richese.noFieldEvent);
assert.deepEqual(allowances(game), allowances(original));
reject(game, f.richese, nativeAction(f, 5), /event|stale/i);
reject(game, f.richese, nativeAction(f, 3, { noFieldEvent: player(game, f.richese).noFieldEvent }), /twice|same|row/i);
const revealed = structuredClone(game);
game = act(game, f.richese, nativeAction(f, 5, { noFieldEvent: player(game, f.richese).noFieldEvent }));
game = passResponses(game);
assert.equal(player(game, f.richese).noField!.deployed!.tokenId, f.tokens.find(token => token.value === 5)!.id);
assert.equal(player(game, f.richese).reserves, player(revealed, f.richese).reserves);
assert.equal(player(game, f.richese).spice, richese.spice);
assert.deepEqual(allowances(game), allowances(original));
finishEntrant(f, game);
});
void test('allied zero grant reveals the actual older supplier marker only after recipient consent and neutral prevention passes', () => {
const f = fixture({ beneficiary: 'allied', priorMarker: true, karama: true }), before = structuredClone(f.game);
let game = requestAllied(f);
game = offerAllied(f, game, 0, f.beneficiaryID);
assert.deepEqual(material(game), material(before));
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
assert.equal(game.response?.kind, 'richeseNoField');
assert.deepEqual(material(game), material(before));
game = passResponses(game);
assert.equal(player(game, f.richese).forces['carthag:11'], 3);
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves - 3);
assert.equal(player(game, f.richese).noField!.deployed, null);
assert.equal(player(game, f.richese).noField!.lastShipped, f.tokens.find(token => token.value === 0)!.id);
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves);
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`] ?? 0, 0);
assert.equal(player(game, f.beneficiaryID).spice, player(before, f.beneficiaryID).spice - 1);
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + 1);
assert.deepEqual(allowances(game), allowances(before));
finishEntrant(f, game);
});
for (const who of ['supplier', 'recipient'] as const) {
void test(`allied Guild substitution ${who} refusal returns the same free physical grant without payment or token changes`, () => {
const f = fixture({ beneficiary: 'allied' }), before = structuredClone(f.game);
let game = requestAllied(f);
if (who === 'recipient') game = offerAllied(f, game, 3);
game = act(game, who === 'supplier' ? f.richese : f.beneficiaryID,
{ type: 'decision', event: f.event, declineNoField: true });
assert.equal(game.decision?.player, f.beneficiaryID);
assert.ok(game.pendingAmbassador); assert.equal(game.pendingAmbassador.event, f.event);
assert.equal(game.pendingAmbassador.stage, 'ship');
assert.deepEqual(material(game), material(before)); assert.deepEqual(allowances(game), allowances(before));
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, amount: 4,
territory: f.territory, sector: f.sector, elite: 0 });
game = advance(game, clean);
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 4);
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 4);
for (const p of game.players) assert.equal(p.spice, player(before, p.id).spice);
assert.deepEqual(player(game, f.richese).noField, player(before, f.richese).noField);
assert.deepEqual(allowances(game), allowances(before)); finishEntrant(f, game);
});
}
void test('allied five cannot enlarge the four-force grant and malformed native mixtures preserve the live opportunity', () => {
const allied = fixture({ beneficiary: 'allied' });
const requested = requestAllied(allied);
assert.ok(player(requested, allied.beneficiaryID).reserves > 4);
reject(requested, allied.richese, { type: 'decision', event: allied.event,
offerNoField: allied.tokens.find(token => token.value === 5)!.id,
noFieldEvent: player(requested, allied.richese).noFieldEvent, payer: allied.richese, elite: 0 }, /four|4/i);
const own = fixture({ beneficiary: 'own' });
for (const extra of [{ amount: 4 }, { elite: 1 }, { allyPayment: 1 }, { noFieldEvent: 'stale-selection' },
{ event: 'stale-parent' }, { alliedNoField: true }])
reject(own.game, own.richese, nativeAction(own, 3, extra));
reject(own.game, own.entrantHark, nativeAction(own, 3));
let game = act(own.game, own.richese, nativeAction(own, 3));
game = passResponses(game); finishEntrant(own, game);
});
for (const beneficiary of ['own', 'allied'] as const) {
void test(`actual held Karama prevents the ${beneficiary} Guild marker only, preserving ordinary shipment state and physical fallback`, () => {
const f = fixture({ beneficiary, karama: true }), before = structuredClone(f.game);
assert.ok(f.karama); assert.ok(player(f.game, f.entrantHark).hand.some(card => card.id === f.karama));
let game = beneficiary === 'own' ? act(f.game, f.richese, nativeAction(f, 3)) : requestAllied(f);
if (beneficiary === 'allied') {
game = offerAllied(f, game, 3, f.beneficiaryID);
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
}
assert.equal(game.response?.kind, 'richeseNoField');
assert.deepEqual(material(game), material(before));
game = act(game, f.entrantHark, { type: 'card', mode: 'cancel', card: f.karama });
game = advance(game, state => !state.response && !state.pendingTreacheryDiscard);
assert.deepEqual(material(game), material(before)); assert.deepEqual(allowances(game), allowances(before));
assert.ok(game.pendingAmbassador); assert.equal(game.pendingAmbassador.event, f.event);
assert.equal(game.pendingAmbassador.stage, 'ship');
assert.equal(game.decision?.player, f.beneficiaryID);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(player(game, f.richese).noFieldBlockedTurn, player(before, f.richese).noFieldBlockedTurn);
assert.deepEqual(game.richeseAllyBlocked, before.richeseAllyBlocked);
if (beneficiary === 'own') reject(game, f.richese, nativeAction(f, 3));
else reject(game, f.beneficiaryID, { type: 'decision', event: f.event, requestNoField: true,
territory: f.territory, sector: f.sector });
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, amount: 1,
territory: f.territory, sector: f.sector, elite: 0 });
game = advance(game, clean);
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 1);
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 1);
assert.deepEqual(allowances(game), allowances(before)); finishEntrant(f, game);
});
}
@@ -0,0 +1,312 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { MOBILE_STRONGHOLD, TERRITORIES } from '../game/board';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
import {
GuildAmbassadorMarkerError,
markerDestinations,
markerOwner,
quoteGuildAmbassadorMarker as quote,
type GuildAmbassadorMarkerSelection,
} from '../game/guild-ambassador-no-field';
function fixture(faction: FactionId = 'ecaz', advanced = false) {
const g = createGame('GUILDAMBMARKER', newPlayer('p', 'Recipient', faction), advanced);
g.players.push(newPlayer('q', 'Other', 'atreides'),
newPlayer('r', 'Supplier', faction === 'richese' ? 'ecaz' : 'richese'));
Object.assign(g, { status: 'playing', turn: 2, phase: 5, storm: 18,
active: 'q', order: ['p', 'q', 'r'] });
for (const player of g.players)
Object.assign(player, { forces: {}, reserves: 20, tanks: 0, spice: 5,
elites: undefined, hand: [], shipped: true, moved: 2 });
const owner = g.players.find((p) => p.faction === 'richese')!;
owner.noField = createRicheseNoField(['token0', 'token3', 'token5']);
owner.noFieldEvent = 'selection-event';
if (owner.id !== 'p') {
owner.ally = 'p';
g.players[0].ally = owner.id;
}
return g;
}
function selection(extra: Partial<GuildAmbassadorMarkerSelection> = {}): GuildAmbassadorMarkerSelection {
return { tokenId: 'token3', tokenEvent: 'selection-event',
territory: 'sietch_tabr', sector: 14, ...extra };
}
function rejected(g: Game, chosen = selection(), checkFunds = true) {
const before = structuredClone(g), previous = structuredClone(chosen);
assert.throws(() => quote(g, 'p', chosen, checkFunds), GuildAmbassadorMarkerError);
assert.deepEqual(g, before);
assert.deepEqual(chosen, previous);
}
function priorMarker(g: Game, value: 0 | 3 | 5,
territory = 'red_chasm', sector = 7) {
const owner = markerOwner(g, 'p')!;
owner.noField = deployRicheseNoField(owner.noField!, {
tokenId: `token${value}`, controller: owner.id,
location: { territory, sector },
});
}
void test('native zero three and five substitute one free concealed marker with no reserve withdrawal', () => {
for (const advanced of [false, true])
for (const reserves of [0, 2, 20])
for (const value of [0, 3, 5] as const) {
const g = fixture('richese', advanced), p = g.players[0];
p.reserves = reserves;
p.spice = 0;
const chosen = selection({ tokenId: `token${value}` }), before = structuredClone(g);
const result = quote(g, 'p', chosen);
assert.deepEqual({ ...result, marker: { ...result.marker, stateSignature: '' } }, {
player: 'p', amount: 1, elite: 0, territory: 'sietch_tabr', sector: 14,
advisors: false, cost: 0, allyPayment: 0,
marker: { mode: 'own', owner: 'p', tokenId: `token${value}`,
tokenEvent: 'selection-event', value, payer: 'p', ownerPayment: 0,
recipientPayment: 0, stateSignature: '' },
});
assert.ok(result.marker.stateSignature);
assert.deepEqual(g, before);
assert.equal(markerOwner(g, 'p'), p);
assert.ok(markerDestinations(g, 'p').some((to) => to.territory === 'sietch_tabr'));
}
});
void test('allied materialization uses the exact reserve cap and rejects five actual counters instead of truncating', () => {
for (const value of [0, 3, 5] as const)
for (const reserves of [0, 1, 2, 3, 4, 5, 20]) {
const g = fixture();
g.players[0].reserves = reserves;
const chosen = selection({ tokenId: `token${value}` });
if (Math.min(value, reserves) > 4) {
rejected(g, chosen);
} else {
const before = structuredClone(g), result = quote(g, 'p', chosen);
assert.equal(result.amount, Math.min(value, reserves));
assert.equal(result.marker.value, value);
assert.equal(result.marker.mode, 'allied');
assert.equal(result.marker.owner, 'r');
assert.equal(result.cost, 1);
assert.deepEqual(g, before);
}
}
});
void test('allied mandatory fees permit either full payer or only the exact two-spice equal split', () => {
for (const faction of ['ecaz', 'guild'] as const)
for (const stronghold of [false, true])
for (const payer of ['p', 'r', 'both']) {
const g = fixture(faction);
// The independent grant keeps the compulsory printed transport fee,
// including an already purchased rate or the Guild's native rate.
Object.assign(g, { karamaShipping: { player: 'p' } });
const chosen = selection({ payer,
territory: stronghold ? 'sietch_tabr' : 'red_chasm',
sector: stronghold ? 14 : 7 });
if (stronghold && payer === 'both') {
rejected(g, chosen);
continue;
}
const result = quote(g, 'p', chosen), cost = stronghold ? 1 : 2;
assert.equal(result.cost, cost);
assert.equal(result.marker.ownerPayment,
payer === 'r' ? cost : payer === 'both' ? 1 : 0);
assert.equal(result.marker.recipientPayment,
payer === 'p' ? cost : payer === 'both' ? 1 : 0);
assert.equal(result.allyPayment, 0);
}
rejected(fixture(), selection({ payer: 'q' }));
rejected(fixture('richese'), selection({ payer: 'r' }));
const g = fixture();
g.players[2].spice = 0;
rejected(g);
assert.equal(quote(g, 'p', selection({ payer: 'p' })).marker.recipientPayment, 1);
g.players[0].spice = 0;
rejected(g, selection({ payer: 'p' }));
assert.equal(quote(g, 'p', selection({ payer: 'p' }), false).cost, 1);
rejected(g, selection({ payer: 'r' }), false);
});
void test('allied typed reserves are selected within the real amount while native markers always have zero elites', () => {
const g = fixture('emperor'), p = g.players[0];
p.reserves = 4;
p.elites = { reserves: 3, tanks: 0, forces: {}, revived: 0 };
assert.equal(quote(g, 'p', selection()).elite, 2);
assert.equal(quote(g, 'p', selection({ elite: 3 })).elite, 3);
assert.equal(quote(g, 'p', selection({ tokenId: 'token5' })).elite, 3);
for (const elite of [0, 1, 4]) rejected(g, selection({ elite }));
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).elite, 0);
rejected(g, selection({ tokenId: 'token0', elite: 1 }));
rejected(fixture('richese'), selection({ elite: 1 }));
});
void test('native prior markers require an explicit reveal and shared last-token history remains binding', () => {
const g = fixture('richese');
priorMarker(g, 3);
rejected(g, selection({ tokenId: 'token5' }));
assert.throws(() => markerDestinations(g, 'p'), GuildAmbassadorMarkerError);
g.players[0].noField!.deployed = null;
rejected(g, selection());
assert.equal(quote(g, 'p', selection({ tokenId: 'token5' })).amount, 1);
g.players[0].noField!.hiddenReturn = true;
rejected(g, selection());
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).marker.value, 0);
rejected(fixture(), selection({ tokenEvent: 'old-selection-event' }));
rejected(fixture(), selection({ tokenId: 'unknown-token' }));
});
void test('allied prior reveal uses the supplier stock before checking actual destination occupancy', () => {
for (const [oldValue, ownerReserves, allowed] of [
[0, 20, true], [3, 0, true], [3, 1, false], [5, 2, false],
] as const) {
const g = fixture('emperor'), owner = g.players[2];
g.players[0].reserves = 4;
owner.reserves = ownerReserves;
priorMarker(g, oldValue, 'sietch_tabr', 14);
const chosen = selection({ tokenId: oldValue === 5 ? 'token3' : 'token5' });
if (allowed) {
const before = structuredClone(g), result = quote(g, 'p', chosen);
assert.equal(result.amount, 4);
assert.ok(markerDestinations(g, 'p').some((to) => to.territory === 'sietch_tabr'));
assert.deepEqual(g, before);
} else {
rejected(g, chosen);
assert.ok(!markerDestinations(g, 'p').some((to) => to.territory === 'sietch_tabr'));
}
assert.equal(owner.noField!.deployed!.tokenId, `token${oldValue}`);
assert.equal(owner.reserves, ownerReserves);
}
const g = fixture();
priorMarker(g, 3);
rejected(g); // A compulsory reveal never clears the shared last-shipped ban.
const before = structuredClone(g);
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).amount, 0);
assert.deepEqual(g, before);
});
void test('a No-Field recipient must be Richese or its current reciprocal off-planet ally', () => {
const mutations: ((g: Game) => void)[] = [
(g) => { g.players[0].ally = null; },
(g) => { g.players[2].ally = null; },
(g) => { g.players[0].ally = 'q'; },
(g) => { g.players[0].faction = 'fremen'; },
(g) => { g.players[2].faction = 'harkonnen'; },
];
for (const mutate of mutations) {
const g = fixture();
mutate(g);
rejected(g);
}
assert.equal(markerOwner(fixture(), 'q'), null);
assert.equal(markerOwner(fixture(), 'missing'), null);
});
void test('printed sectors, storm, mobile-stronghold exclusion and occupying sides apply to markers', () => {
const g = fixture('richese');
g.mobileStronghold = { location: 'wind_pass:14' };
g.storm = 14;
for (const to of [
{ territory: 'sietch_tabr', sector: 14 },
{ territory: 'wind_pass', sector: 14 },
{ territory: 'sietch_tabr', sector: 15 },
{ territory: MOBILE_STRONGHOLD, sector: 0 },
{ territory: 'homeworld', sector: 0 },
{ territory: 'discovery', sector: 0 },
]) rejected(g, selection(to));
assert.equal(quote(g, 'p', selection({ territory: 'wind_pass', sector: 15 })).amount, 1);
assert.equal(quote(g, 'p', selection({ territory: 'polar_sink', sector: 0 })).amount, 1);
assert.ok(markerDestinations(g, 'p').every((to) =>
to.sector !== 14 && to.territory !== MOBILE_STRONGHOLD));
g.storm = 18;
g.players[1].forces = { 'sietch_tabr:14': 1 };
g.players[2].forces = { 'sietch_tabr:14': 1 };
rejected(g);
});
void test('native Ecaz co-occupation and existing Bene Gesserit advisor stance retain shared entry rules', () => {
const g = fixture();
priorMarker(g, 3, 'sietch_tabr', 14);
g.players[1].forces = { 'sietch_tabr:14': 1 };
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).advisors, false);
const bg = fixture('beneGesserit', true);
bg.players[0].forces = { 'sietch_tabr:14': 1 };
bg.players[0].advisors = { sietch_tabr: { lockedTurn: bg.turn } };
bg.players[1].forces = { 'sietch_tabr:14': 1 };
bg.players[2].forces = { 'sietch_tabr:14': 1 };
assert.equal(quote(bg, 'p', selection()).advisors, true);
bg.advanced = false;
rejected(bg);
});
void test('malformed physical marker or typed custody and malformed selections reject without mutation', () => {
const mutations: ((g: Game) => void)[] = [
(g) => { g.players[0].reserves = -1; },
(g) => { g.players[2].reserves = NaN; },
(g) => { g.players[0].elites = { reserves: 21, tanks: 0, forces: {}, revived: 0 }; },
(g) => { g.players[1].forces = { 'unknown:0': 1 }; },
(g) => { g.players[1].forces = { 'red_chasm:7': -1 }; },
(g) => { g.players[1].elites = { reserves: 0, tanks: 0, revived: 0,
forces: { 'red_chasm:7': 1 } }; },
(g) => { g.players[2].noField!.tokens[0].id = 'token3'; },
(g) => { g.players[2].noFieldEvent = ''; },
(g) => { priorMarker(g, 0); g.players[2].noField!.deployed!.controller = 'p'; },
(g) => { g.players[0].forces = { 'sietch_tabr:14': Number.MAX_SAFE_INTEGER }; },
(g) => { g.storm = 19; },
];
for (const mutate of mutations) {
const g = fixture();
mutate(g);
rejected(g);
}
for (const extra of [
{ tokenId: null }, { tokenEvent: 1 }, { sector: 1.5 }, { payer: '' },
{ elite: -1 }, { elite: NaN }, { elite: '0' },
]) rejected(fixture(), { ...selection(), ...extra } as unknown as GuildAmbassadorMarkerSelection);
});
void test('quotes and destination choices never consult cards RNG or an unchecked recipient balance', () => {
const g = fixture(), chosen = selection({ payer: 'p' });
priorMarker(g, 0);
const expected = quote(g, 'p', chosen, false), destinations = markerDestinations(g, 'p');
for (const player of g.players)
for (const key of ['hand', 'leaders', 'traitors'])
Object.defineProperty(player, key, { get() { throw new Error(`private ${key}`); } });
Object.defineProperty(g.players[0], 'spice', {
get() { throw new Error('unchecked private recipient balance'); },
});
for (const key of ['deck', 'discard', 'aid'])
Object.defineProperty(g, key, { get() { throw new Error(`unneeded ${key}`); } });
const random = Math.random;
Math.random = () => { throw new Error('unexpected RNG'); };
try {
assert.deepEqual(quote(g, 'p', chosen, false), expected);
assert.deepEqual(markerDestinations(g, 'p'), destinations);
} finally {
Math.random = random;
}
});
void test('the destination domain remains finite and every zero-reserve native choice has a quote witness', () => {
const g = fixture('richese');
g.players[0].reserves = 0;
const before = structuredClone(g), choices = markerDestinations(g, 'p');
assert.ok(choices.length > 0);
assert.equal(new Set(choices.map((to) => `${to.territory}:${to.sector}`)).size, choices.length);
assert.ok(choices.length <= TERRITORIES.flatMap((to) => to.sectors).length);
for (const to of choices) {
const result = quote(g, 'p', selection({ ...to, tokenId: 'token0' }));
assert.equal(result.amount, 1);
assert.equal(result.advisors, to.advisors);
}
const signature = quote(g, 'p', selection()).marker.stateSignature;
assert.equal(quote(g, 'p', selection()).marker.stateSignature, signature);
g.players[1].forces = { 'red_chasm:7': 1 };
assert.notEqual(quote(g, 'p', selection()).marker.stateSignature, signature);
g.players[1].forces = {};
assert.deepEqual(g, before);
choices.length = 0;
assert.ok(markerDestinations(g, 'p').length > 0);
});