Files
Dune/tests/guild-ambassador-no-field-quote.test.ts
T
Quintine 659e2a4a9a 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.
2026-10-24 17:36:12 +11:00

313 lines
14 KiB
TypeScript

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);
});