Files
Dune/tests/guild-ambassador-quote.test.ts
2026-09-08 07:37:52 +10:00

374 lines
12 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, type Game, type Action } from '../game/engine';
import { FACTIONS, type FactionId } from '../game/catalog';
import { MOBILE_STRONGHOLD, MOBILE_LOCATION, TERRITORIES } from '../game/board';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
import {
quoteGuildAmbassadorShipment as quote,
guildAmbassadorShipments as domain,
GuildAmbassadorShipmentError,
} from '../game/guild-ambassador';
function fixture(faction: FactionId = 'ecaz', advanced = true) {
const g = createGame(
'GUILDAMBQUOTE',
newPlayer('p', 'Beneficiary', faction),
advanced,
);
const others = FACTIONS.filter((f) => f.id !== faction);
g.players.push(
newPlayer('q', 'Other', others[0].id),
newPlayer('r', 'Third', others[1].id),
);
Object.assign(g, {
status: 'playing',
turn: 2,
phase: 1,
storm: 18,
active: 'q',
order: ['q', 'p', 'r'],
movementRemaining: ['q', 'r'],
});
for (const p of g.players)
Object.assign(p, {
forces: {},
reserves: 20,
tanks: 0,
hand: [],
spice: 0,
elites: undefined,
});
return g;
}
const ship = (extra: Partial<Action> = {}): Action => ({
type: 'decision',
event: 'ambassador-event',
amount: 4,
territory: 'sietch_tabr',
sector: 14,
...extra,
});
function rejected(g: Game, action: Action) {
const before = structuredClone(g),
a = structuredClone(action);
assert.throws(() => quote(g, 'p', action), GuildAmbassadorShipmentError);
assert.deepEqual(g, before);
assert.deepEqual(action, a);
}
function marker(g: Game, owner: number, value: 0 | 3 | 5) {
const p = g.players[owner];
p.faction = 'richese';
p.noField = deployRicheseNoField(createRicheseNoField(['n0', 'n3', 'n5']), {
tokenId: `n${value}`,
controller: p.id,
location: { territory: 'sietch_tabr', sector: 14 },
});
p.noFieldEvent = 'marker-event';
}
void test('all twelve beneficiaries receive the same independent free physical reserve grant in Basic and Advanced', () => {
for (const faction of FACTIONS)
for (const advanced of [false, true]) {
const g = fixture(faction.id, advanced);
g.players[0].shipped = true;
g.players[0].moved = 2;
g.hajr = ['p'];
g.aid = {};
const before = structuredClone(g),
a = ship(),
beforeAction = structuredClone(a);
assert.deepEqual(quote(g, 'p', a), {
player: 'p',
amount: 4,
elite: 0,
territory: 'sietch_tabr',
sector: 14,
advisors: false,
cost: 0,
allyPayment: 0,
});
assert.deepEqual(g, before);
assert.deepEqual(a, beforeAction);
assert.equal(domain(g, 'p').maximum, 4);
g.phase = 5;
assert.deepEqual(quote(g, 'p', a), quote(before, 'p', a));
}
});
void test('reserve bounds admit one through four actual forces and zero reserves yield no destinations', () => {
for (const reserves of [0, 1, 2, 3, 4, 20]) {
const g = fixture();
g.players[0].reserves = reserves;
const d = domain(g, 'p');
assert.equal(d.maximum, Math.min(reserves, 4));
assert.equal(d.destinations.length === 0, reserves === 0);
for (let n = 1; n <= Math.min(4, reserves); n++)
assert.equal(quote(g, 'p', ship({ amount: n })).amount, n);
rejected(g, ship({ amount: Math.min(4, reserves) + 1 }));
}
for (const amount of [
0,
-1,
1.5,
NaN,
Infinity,
Number.MAX_SAFE_INTEGER + 1,
'2',
null,
undefined,
])
rejected(fixture(), ship({ amount }));
});
void test('typed reserve allocation selects the minimum required elites without adding them to physical amount', () => {
for (const faction of ['fremen', 'emperor', 'ixians'] as const) {
const g = fixture(faction);
g.players[0].reserves = 4;
g.players[0].elites = { reserves: 3, tanks: 0, revived: 0, forces: {} };
assert.equal(quote(g, 'p', ship({ amount: 3 })).elite, 2);
assert.equal(quote(g, 'p', ship({ amount: 3, elite: 3 })).elite, 3);
assert.equal(quote(g, 'p', ship({ amount: 1 })).elite, 0);
assert.equal(quote(g, 'p', ship({ amount: 4 })).elite, 3);
for (const elite of [-1, 0, 1, 4, 1.5, NaN, Infinity, null, '2'])
rejected(g, ship({ amount: 3, elite }));
assert.equal(domain(g, 'p').eliteReserves, 3);
}
rejected(fixture(), ship({ elite: 1 }));
});
void test('storm blocks the chosen sector even for Fremen while other sectors in the territory remain usable', () => {
const g = fixture('fremen');
g.storm = 14;
rejected(g, ship());
assert.equal(
quote(g, 'p', ship({ territory: 'wind_pass', sector: 15 })).cost,
0,
);
rejected(g, ship({ territory: 'wind_pass', sector: 14 }));
assert.ok(domain(g, 'p').destinations.every((d) => d.sector !== 14));
assert.ok(
domain(g, 'p').destinations.some(
(d) => d.territory === 'wind_pass' && d.sector === 15,
),
);
for (const destination of [
{ territory: 'sietch_tabr', sector: 15 },
{ territory: 'homeworld', sector: 0 },
{ territory: 'discovery', sector: 0 },
{ territory: 'missing', sector: 2 },
])
rejected(g, ship(destination));
});
void test('only Ixians can ship directly into a placed canonical mobile stronghold', () => {
for (const faction of FACTIONS) {
const g = fixture(faction.id);
g.mobileStronghold = { location: 'wind_pass:14' };
g.storm = 14;
const a = ship({ territory: MOBILE_STRONGHOLD, sector: 0 });
if (faction.id === 'ixians') {
assert.equal(quote(g, 'p', a).territory, MOBILE_STRONGHOLD);
assert.ok(
domain(g, 'p').destinations.some(
(d) => d.territory === MOBILE_STRONGHOLD,
),
);
} else {
rejected(g, a);
assert.ok(
!domain(g, 'p').destinations.some(
(d) => d.territory === MOBILE_STRONGHOLD,
),
);
}
}
const g = fixture('ixians');
rejected(g, ship({ territory: MOBILE_STRONGHOLD, sector: 0 }));
for (const pointer of [
'wind_pass',
MOBILE_LOCATION,
'wind_pass:1',
'unknown:0',
]) {
g.mobileStronghold = { location: pointer };
assert.throws(() => domain(g, 'p'), GuildAmbassadorShipmentError);
}
});
void test('reciprocal Ecaz co-occupation counts one stronghold side in both beneficiary orientations', () => {
for (const ecazBeneficiary of [false, true]) {
const g = fixture(ecazBeneficiary ? 'ecaz' : 'fremen');
g.players[1].faction = ecazBeneficiary ? 'fremen' : 'ecaz';
g.players[0].ally = 'q';
g.players[1].ally = 'p';
g.players[1].forces = { 'sietch_tabr:14': 2 };
g.players[2].forces = { 'sietch_tabr:14': 2 };
assert.equal(quote(g, 'p', ship()).advisors, false);
g.players[1].ally = null;
rejected(g, ship());
}
const g = fixture('ecaz');
g.players[1].forces = { 'sietch_tabr:14': 1 };
g.players[2].forces = { 'sietch_tabr:14': 1 };
rejected(g, ship());
});
void test('ordinary non-Ecaz alliance exclusion and Polar Sink exemption remain intact', () => {
const g = fixture('fremen');
g.players[0].ally = 'q';
g.players[1].ally = 'p';
g.players[1].forces = { 'red_chasm:7': 1, 'polar_sink:0': 1 };
rejected(g, ship({ territory: 'red_chasm', sector: 7 }));
assert.equal(
quote(g, 'p', ship({ territory: 'polar_sink', sector: 0 })).advisors,
false,
);
});
void test('direct BG reserve shipment joins own advisors but otherwise arrives as fighters with no new stance choice', () => {
const g = fixture('beneGesserit');
g.players[1].forces = { 'sietch_tabr:14': 1 };
assert.equal(quote(g, 'p', ship()).advisors, false);
g.players[0].forces = { 'sietch_tabr:14': 1 };
g.players[0].reserves = 19;
g.players[0].advisors = { sietch_tabr: { lockedTurn: g.turn } };
g.players[2].forces = { 'sietch_tabr:14': 1 };
assert.equal(quote(g, 'p', ship()).advisors, true);
assert.ok(
domain(g, 'p').destinations.find((d) => d.territory === 'sietch_tabr')
?.advisors,
);
const basic = structuredClone(g);
basic.advanced = false;
rejected(basic, ship());
delete g.players[0].forces['sietch_tabr:14'];
rejected(g, ship());
});
void test('opponent No-Fields count one occupying faction without reading hidden denomination', () => {
const outputs = [];
for (const value of [0, 3, 5] as const) {
const g = fixture('ecaz');
marker(g, 1, value);
g.players[2].forces = { 'sietch_tabr:14': 1 };
rejected(g, ship());
outputs.push(domain(g, 'p'));
}
assert.deepEqual(outputs[0], outputs[1]);
assert.deepEqual(outputs[1], outputs[2]);
for (const field of ['noField', 'alliedNoField'])
for (const value of [true, false, null, 'token', {}])
rejected(fixture('richese'), ship({ [field]: value }));
});
void test('private hands balances token identity and random sources are never consulted by quote or domain', () => {
const g = fixture('ecaz');
marker(g, 1, 3);
const expected = domain(g, 'p'),
expectedQuote = quote(g, 'p', ship({ territory: 'red_chasm', sector: 7 }));
for (const player of g.players) {
for (const key of ['hand', 'spice', 'traitors', 'traitorChoices'])
Object.defineProperty(player, key, {
get() {
throw new Error(`private ${key}`);
},
});
}
const markerState = g.players[1].noField!;
Object.defineProperty(markerState, 'tokens', {
get() {
throw new Error('private token inventory');
},
});
for (const key of ['value', 'tokenId', 'controller'])
Object.defineProperty(markerState.deployed!, key, {
get() {
throw new Error(`private marker ${key}`);
},
});
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(domain(g, 'p'), expected);
assert.deepEqual(
quote(g, 'p', ship({ territory: 'red_chasm', sector: 7 })),
expectedQuote,
);
} finally {
Math.random = random;
}
});
void test('safe physical inventories and resulting totals reject malformed saved custody atomically', () => {
const mutations: ((g: Game) => void)[] = [
(g) => {
g.players[0].reserves = -1;
},
(g) => {
g.players[0].reserves = NaN;
},
(g) => {
g.players[0].elites = { reserves: 21, tanks: 0, revived: 0, forces: {} };
},
(g) => {
g.players[1].forces = { 'unknown:0': 1 };
},
(g) => {
g.players[1].forces = { 'wind_pass:14': -1 };
},
(g) => {
g.players[1].elites = {
reserves: 0,
tanks: 0,
revived: 0,
forces: { 'wind_pass:14': 1 },
};
},
(g) => {
g.players[0].forces = { 'sietch_tabr:14': Number.MAX_SAFE_INTEGER };
},
(g) => {
g.players[1].forces = {
'wind_pass:14': Number.MAX_SAFE_INTEGER,
'wind_pass:15': 1,
};
},
(g) => {
g.players[1].id = 'p';
},
(g) => {
g.storm = 19;
},
];
for (const mutate of mutations) {
const g = fixture();
mutate(g);
rejected(g, ship());
}
});
void test('the finite domain has no duplicate destinations and every listed physical amount has an actual quote witness', () => {
for (const faction of FACTIONS) {
const g = fixture(faction.id);
const d = domain(g, 'p');
const before = structuredClone(g);
assert.equal(
new Set(d.destinations.map((t) => `${t.territory}:${t.sector}`)).size,
d.destinations.length,
);
for (const destination of d.destinations)
for (let amount = 1; amount <= d.maximum; amount++) {
const result = quote(g, 'p', ship({ ...destination, amount }));
assert.equal(result.advisors, destination.advisors);
assert.equal(result.amount, amount);
}
assert.deepEqual(g, before);
assert.ok(
d.destinations.length <= TERRITORIES.flatMap((t) => t.sectors).length,
);
d.destinations.length = 0;
assert.ok(domain(g, 'p').destinations.length > 0);
}
});