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

451 lines
15 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import {
quoteFremenAmbassadorMove as quote,
fremenAmbassadorMovement as domain,
FremenAmbassadorMoveError,
} from '../game/fremen-ambassador';
import { location, MOBILE_STRONGHOLD } from '../game/board';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
function fixture(faction: FactionId = 'ecaz', advanced = false) {
const g = createGame(
'FREMAMBQUOTE',
newPlayer('p', 'Beneficiary', faction),
advanced,
);
g.players.push(
newPlayer('q', 'Other', 'atreides'),
newPlayer('r', 'Third', 'emperor'),
);
Object.assign(g, {
status: 'playing',
turn: 2,
phase: 5,
storm: 18,
order: ['q', 'p', 'r'],
active: 'q',
});
for (const p of g.players)
Object.assign(p, { forces: {}, elites: undefined, hand: [], reserves: 20 });
g.players[0].forces = { 'wind_pass:14': 4, 'wind_pass:15': 3 };
return g;
}
function move(extra: Partial<Action> = {}): Action {
return {
type: 'decision',
event: 'ambassador-event',
forces: { 'wind_pass:14': 2 },
territory: 'red_chasm',
sector: 7,
...extra,
};
}
function rejects(g: Game, a: Action, reason?: RegExp) {
const before = structuredClone(g);
assert.throws(() => quote(g, 'p', a), reason ?? FremenAmbassadorMoveError);
assert.deepEqual(g, before);
}
function destination(g: Game, from: string, to: string, sector: number) {
return domain(g, 'p')
.sources.find((s) => s.territory === from)
?.destinations.find((d) => d.territory === to && d.sector === sector);
}
function marker(
g: Game,
value: 0 | 3 | 5 = 0,
from = 'wind_pass',
sector = 14,
) {
const state = createRicheseNoField(['nf0', 'nf3', 'nf5']);
const tokenId = state.tokens.find((t) => t.value === value)!.id;
g.players[0].noField = deployRicheseNoField(state, {
tokenId,
controller: 'p',
location: { territory: from, sector },
});
g.players[0].noFieldEvent = 'marker-event';
return { tokenId, event: 'marker-event' };
}
void test('direct multi-sector relocation preserves all ordinary controls and typed force totals', () => {
const g = fixture('ixians', true);
g.players[0].elites = {
reserves: 3,
tanks: 0,
revived: 0,
forces: { 'wind_pass:14': 3, 'wind_pass:15': 1 },
};
g.players[0].moved = 2;
g.players[0].shipped = true;
g.hajr = ['p'];
const a = move({
forces: { 'wind_pass:14': 3, 'wind_pass:15': 2 },
eliteForces: { 'wind_pass:14': 2, 'wind_pass:15': 1 },
});
const before = structuredClone(g),
beforeAction = structuredClone(a);
assert.deepEqual(quote(g, 'p', a), {
player: 'p',
group: [
['wind_pass:14', 3],
['wind_pass:15', 2],
],
eliteGroup: { 'wind_pass:14': 2, 'wind_pass:15': 1 },
elite: 3,
origin: 'wind_pass',
total: 5,
to: 'red_chasm',
sector: 7,
advisors: false,
wantsFighters: false,
});
assert.deepEqual(g, before);
assert.deepEqual(a, beforeAction);
assert.ok(
destination(g, 'wind_pass', 'red_chasm', 7),
'no normal adjacency or active-seat restriction',
);
});
void test('elite minimum is canonical; extra elites are never added to physical count', () => {
const g = fixture('fremen', true);
g.players[0].elites = {
reserves: 0,
tanks: 0,
revived: 0,
forces: { 'wind_pass:14': 3 },
};
const selected = quote(g, 'p', move({ forces: { 'wind_pass:14': 3 } }));
assert.equal(selected.elite, 2);
assert.equal(selected.total, 3);
for (const elite of [-1, 0, 1, 4, 1.5])
rejects(
g,
move({
forces: { 'wind_pass:14': 3 },
eliteForces: { 'wind_pass:14': elite },
}),
);
rejects(g, move({ eliteForces: { 'wind_pass:15': 1 } }));
});
for (const [name, action] of [
['empty', move({ forces: {} })],
['negative', move({ forces: { 'wind_pass:14': -1 } })],
['fractional', move({ forces: { 'wind_pass:14': 0.5 } })],
['unavailable', move({ forces: { 'wind_pass:14': 5 } })],
['invalid source', move({ forces: { 'wind_pass:8': 1 } })],
['invalid destination', move({ territory: 'arrakeen', sector: 11 })],
['unchanged source', move({ territory: 'wind_pass', sector: 14 })],
[
'mixed unchanged source',
move({
forces: { 'wind_pass:14': 1, 'wind_pass:15': 1 },
territory: 'wind_pass',
sector: 14,
}),
],
] as const)
void test(`invalid ${name} selection rejects without mutation`, () =>
rejects(fixture(), action));
void test('different source territories cannot be combined', () => {
const g = fixture();
g.players[0].forces['carthag:11'] = 1;
rejects(g, move({ forces: { 'wind_pass:14': 1, 'carthag:11': 1 } }));
});
void test('same-territory changed sectors are present even when another source sector is the destination', () => {
const g = fixture();
const a = move({ territory: 'wind_pass', sector: 15 });
assert.equal(quote(g, 'p', a).origin, 'wind_pass');
assert.ok(destination(g, 'wind_pass', 'wind_pass', 15));
assert.ok(destination(g, 'wind_pass', 'wind_pass', 14));
});
void test('source and destination storms filter sectors, including partly covered territories, while sector zero remains clear', () => {
const g = fixture();
g.storm = 14;
rejects(g, move(), /storm/);
rejects(
g,
move({ forces: { 'wind_pass:15': 1 }, territory: 'wind_pass', sector: 14 }),
/storm/,
);
assert.deepEqual(
domain(g, 'p')
.sources.find((s) => s.territory === 'wind_pass')!
.sectors.map((s) => s.key),
['wind_pass:15'],
);
assert.ok(destination(g, 'wind_pass', 'wind_pass', 16));
assert.equal(destination(g, 'wind_pass', 'wind_pass', 14), undefined);
g.storm = 0;
g.players[0].forces = { 'polar_sink:0': 2 };
assert.ok(destination(g, 'polar_sink', 'red_chasm', 7));
g.players[0].forces = { 'red_chasm:7': 2 };
assert.ok(destination(g, 'red_chasm', 'polar_sink', 0));
});
void test('HMS exists only when placed; non-Ixians enter only from its pointing territory', () => {
const g = fixture();
rejects(g, move({ territory: MOBILE_STRONGHOLD, sector: 0 }));
assert.equal(destination(g, 'wind_pass', MOBILE_STRONGHOLD, 0), undefined);
g.mobileStronghold = { location: 'wind_pass:14' };
assert.ok(destination(g, 'wind_pass', MOBILE_STRONGHOLD, 0));
g.mobileStronghold.location = 'carthag:11';
rejects(g, move({ territory: MOBILE_STRONGHOLD, sector: 0 }), /points/);
g.players[0].faction = 'ixians';
assert.ok(destination(g, 'wind_pass', MOBILE_STRONGHOLD, 0));
});
for (const value of [0, 3, 5] as const)
void test(`No-Field ${value} relocates alone or with physical forces without exposing denomination`, () => {
const g = fixture('richese');
const selected = marker(g, value);
const before = structuredClone(g);
const a = move({ forces: {}, noField: selected });
const result = quote(g, 'p', a);
assert.equal(result.total, 1);
assert.equal(result.elite, 0);
assert.deepEqual(result.noField, { ...selected, from: 'wind_pass:14' });
assert.ok(!JSON.stringify(result).includes('value'));
assert.equal(quote(g, 'p', move({ noField: selected })).total, 3);
assert.deepEqual(g, before);
g.mobileStronghold = { location: 'wind_pass:14' };
assert.equal(
quote(g, 'p', { ...a, territory: MOBILE_STRONGHOLD, sector: 0 }).to,
MOBILE_STRONGHOLD,
);
rejects(
g,
move({ noField: { ...selected, event: 'ambassador-event' } }),
/current/,
);
rejects(
g,
move({ noField: selected, territory: 'wind_pass', sector: 14 }),
/change/,
);
});
void test('marker-only sources are represented and storm markers cannot create sources', () => {
const g = fixture('richese');
g.players[0].forces = {};
marker(g);
assert.equal(domain(g, 'p').sources[0].sectors.length, 0);
assert.deepEqual(domain(g, 'p').sources[0].marker, {
tokenId: 'nf0',
event: 'marker-event',
sector: 14,
});
g.storm = 14;
assert.deepEqual(domain(g, 'p'), { sources: [] });
});
void test('Ecaz and reciprocal ally may co-occupy and count as one faction for stronghold capacity', () => {
const g = fixture();
g.players[0].ally = 'q';
g.players[1].ally = 'p';
g.players[1].forces = { 'arrakeen:10': 2 };
g.players[2].forces = { 'arrakeen:10': 2 };
assert.doesNotThrow(() =>
quote(g, 'p', move({ territory: 'arrakeen', sector: 10 })),
);
g.players[0].faction = 'guild';
rejects(g, move({ territory: 'arrakeen', sector: 10 }), /ally/);
g.players[0].faction = 'ecaz';
g.players[1].ally = null;
rejects(g, move({ territory: 'arrakeen', sector: 10 }), /reciprocal ally/);
});
void test('an already occupying Ecaz/allied pair counts as one opponent faction', () => {
const g = fixture('guild');
g.players[1].faction = 'ecaz';
g.players[1].ally = 'r';
g.players[2].ally = 'q';
g.players[1].forces = { 'arrakeen:10': 2 };
g.players[2].forces = { 'arrakeen:10': 2 };
assert.doesNotThrow(() =>
quote(g, 'p', move({ territory: 'arrakeen', sector: 10 })),
);
g.players[1].ally = null;
g.players[2].ally = null;
rejects(g, move({ territory: 'arrakeen', sector: 10 }), /three/);
});
void test('advisor stance avoids fighter capacity; optional flip has exact ordinary occupancy and lock limits', () => {
const g = fixture('beneGesserit', true);
g.players[0].advisors = { wind_pass: {} };
g.players[1].forces = { 'arrakeen:10': 1 };
g.players[2].forces = { 'arrakeen:10': 1 };
const a = move({ territory: 'arrakeen', sector: 10 });
assert.equal(quote(g, 'p', a).advisors, true);
assert.equal(destination(g, 'wind_pass', 'arrakeen', 10)!.canFight, false);
rejects(g, { ...a, fighters: true }, /three/);
g.players[2].forces = {};
assert.equal(destination(g, 'wind_pass', 'arrakeen', 10)!.canFight, true);
assert.equal(quote(g, 'p', { ...a, fighters: true }).wantsFighters, true);
g.phase = 1;
assert.equal(
quote(g, 'p', { ...a, fighters: true }).wantsFighters,
true,
'arrival choice is not limited to the ordinary movement phase',
);
g.players[0].advisors.wind_pass.lockedTurn = 2;
assert.equal(quote(g, 'p', a).lockedTurn, 2);
rejects(g, { ...a, fighters: true }, /flip/);
assert.equal(destination(g, 'wind_pass', 'arrakeen', 10)!.canFight, false);
});
void test('advisors joining own fighters cannot evade a current-turn lock; empty destination becomes fighters', () => {
const g = fixture('beneGesserit', true);
g.players[0].advisors = { wind_pass: { lockedTurn: 2 } };
g.players[0].forces['arrakeen:10'] = 1;
g.players[1].forces = { 'arrakeen:10': 1 };
rejects(g, move({ territory: 'arrakeen', sector: 10 }), /New advisors/);
assert.equal(quote(g, 'p', move()).advisors, false);
});
void test('Ecaz Occupy permits an unlocked allied BG arrival flip while preserving advisor locks and own stance', () => {
const g = fixture('beneGesserit', true);
g.players[0].advisors = { wind_pass: {} };
g.players[0].ally = 'q';
g.players[1].faction = 'ecaz';
g.players[1].ally = 'p';
g.players[1].forces = { 'arrakeen:10': 2 };
g.players[2].forces = { 'arrakeen:10': 1 };
const a = move({ territory: 'arrakeen', sector: 10, fighters: true });
assert.equal(quote(g, 'p', a).wantsFighters, true);
assert.equal(destination(g, 'wind_pass', 'arrakeen', 10)!.canFight, true);
g.players[0].advisors.wind_pass.lockedTurn = g.turn;
rejects(g, a, /flip/);
assert.equal(destination(g, 'wind_pass', 'arrakeen', 10)!.canFight, false);
delete g.players[0].advisors.wind_pass.lockedTurn;
g.players[0].forces['arrakeen:10'] = 1;
g.players[0].advisors.arrakeen = {};
rejects(g, a, /flip/);
});
void test('only current phase-five applied Baliset prevents entry, not same-territory movement or another phase', () => {
const g = fixture();
g.players[1].faction = 'choam';
g.players[1].forces = { 'red_chasm:7': 1 };
g.choamBaliset = [{ turn: 2, player: 'p', territory: 'red_chasm' }];
rejects(g, move(), /Baliset/);
assert.equal(destination(g, 'wind_pass', 'red_chasm', 7), undefined);
g.phase = 1;
assert.doesNotThrow(() => quote(g, 'p', move()));
g.phase = 5;
g.choamBaliset[0].turn = 1;
assert.doesNotThrow(() => quote(g, 'p', move()));
g.choamBaliset[0] = { turn: 2, player: 'p', territory: 'wind_pass' };
g.players[1].forces = { 'wind_pass:14': 1 };
assert.doesNotThrow(() =>
quote(g, 'p', move({ territory: 'wind_pass', sector: 15 })),
);
});
void test('every advertised destination has a valid minimum selection, including elite-only and marker-only witnesses', () => {
for (const kind of ['physical', 'elite', 'marker'] as const) {
const g = fixture(kind === 'marker' ? 'richese' : 'ixians', true);
if (kind === 'elite')
g.players[0].elites = {
reserves: 0,
tanks: 0,
revived: 0,
forces: { 'wind_pass:14': 4, 'wind_pass:15': 3 },
};
if (kind === 'marker') {
g.players[0].forces = {};
marker(g);
}
const snapshot = structuredClone(g),
projected = domain(g, 'p');
for (const source of projected.sources)
for (const to of source.destinations) {
const key = source.sectors.find(
(s) => s.key !== location(to.territory, to.sector),
)?.key;
const a = move({
territory: to.territory,
sector: to.sector,
forces: key ? { [key]: 1 } : {},
...(!key && source.marker
? {
noField: {
tokenId: source.marker.tokenId,
event: source.marker.event,
},
}
: {}),
});
assert.equal(quote(g, 'p', a).advisors, to.advisors);
if (to.canFight)
assert.equal(
quote(g, 'p', { ...a, fighters: true }).wantsFighters,
true,
);
}
assert.deepEqual(g, snapshot);
}
});
void test('private cards, balances, promises and No-Field denomination are not read by either pure API', (t) => {
const g = fixture('richese');
marker(g);
const before = domain(g, 'p');
t.mock.method(globalThis.crypto, 'randomUUID', () => {
throw Error('Relocation sampled an event');
});
t.mock.method(globalThis.crypto, 'getRandomValues', () => {
throw Error('Relocation sampled randomness');
});
for (const p of g.players)
for (const key of ['hand', 'spice', 'traitors'])
Object.defineProperty(p, key, {
get() {
throw Error('Private data read: ' + key);
},
});
for (const key of [
'deck',
'shipmentPromises',
'battlePromises',
'truthtrance',
])
Object.defineProperty(g, key, {
get() {
throw Error('Private data read: ' + key);
},
});
Object.defineProperty(g.players[0].noField!, 'tokens', {
get() {
throw Error('Marker denomination read');
},
});
assert.deepEqual(domain(g, 'p'), before);
assert.equal(
quote(
g,
'p',
move({ forces: {}, noField: { tokenId: 'nf0', event: 'marker-event' } }),
).total,
1,
);
});
void test('malformed board, typed custody, absent ally and overflowing destination reject without mutation', () => {
for (const corrupt of [
(g: Game) => {
g.players[0].forces['red_chasm:19'] = 1;
},
(g: Game) => {
g.players[0].elites = {
reserves: 0,
tanks: 0,
revived: 0,
forces: { 'wind_pass:14': 5 },
};
},
(g: Game) => {
g.players[0].ally = 'missing';
},
(g: Game) => {
g.players[0].forces['red_chasm:7'] = Number.MAX_SAFE_INTEGER;
},
]) {
const g = fixture();
corrupt(g);
rejects(g, move());
}
});