Files
Dune/tests/ecaz-collection-quote.test.ts
T
2026-09-08 07:37:52 +10:00

336 lines
12 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
quoteSpiceCollection,
quoteBattlePhaseAdvance,
BoardResolutionError,
} from '../game/board-resolution-quote';
import { createGame, newPlayer, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
function fixture(ally: FactionId = 'emperor', advanced = false) {
const g = createGame(
'ECAZCOLLECT',
newPlayer('ec', 'Ecaz', 'ecaz'),
advanced,
['ecaz'],
);
g.players.push(
newPlayer('al', 'Ally', ally),
newPlayer('en', 'Enemy', 'harkonnen'),
);
Object.assign(g, {
status: 'playing',
phase: 7,
turn: 2,
storm: 18,
order: ['ec', 'al', 'en'],
spice: {},
});
for (const p of g.players) p.spice = 10;
g.players[0].ally = 'al';
g.players[1].ally = 'ec';
return g;
}
function army(g: Game, id: string, forces: Record<string, number>) {
const p = g.players.find((p) => p.id === id)!;
p.forces = forces;
p.reserves = 20 - Object.values(forces).reduce((a, n) => a + n, 0);
}
const receipt = (q: ReturnType<typeof quoteSpiceCollection>, id: string) =>
q.receipts.find((r) => r.player === id)!;
function conserved(g: Game, q: ReturnType<typeof quoteSpiceCollection>) {
const sum = (values: number[]) => values.reduce((a, n) => a + n, 0);
assert.equal(
sum(Object.values(g.spice)) - sum(Object.values(q.spice)),
sum(q.receipts.map((r) => r.collected)) +
sum(q.shared.map((s) => s.amount)),
);
}
function shared(g = fixture()) {
army(g, 'ec', { 'wind_pass:14': 2 });
army(g, 'al', { 'wind_pass:14': 2 });
g.spice = { 'wind_pass:14': 5 };
return g;
}
for (const advanced of [false, true])
for (const reversed of [false, true])
void test(`${advanced ? 'Advanced' : 'Basic'} shared odd pile enters one escrow lot in ${reversed ? 'ally-first' : 'Ecaz-first'} order`, () => {
const g = shared(fixture('emperor', advanced));
if (reversed) g.order = ['al', 'ec', 'en'];
const before = structuredClone(g);
const q = quoteSpiceCollection(g);
assert.deepEqual(q.shared, [
{ territory: 'wind_pass', ecaz: 'ec', ally: 'al', amount: 5 },
]);
assert.equal(q.spice['wind_pass:14'], 0);
for (const id of ['ec', 'al'])
assert.deepEqual(receipt(q, id), {
player: id,
strongholds: 0,
collected: 0,
balance: 10,
});
assert.deepEqual(g, before);
conserved(g, q);
});
void test('separate eligible sectors pool once per territory without moving collection capacity between deposits', () => {
const g = fixture();
army(g, 'ec', { 'wind_pass:14': 1, 'red_chasm:7': 2 });
army(g, 'al', { 'wind_pass:15': 1 });
g.spice = { 'wind_pass:14': 1, 'wind_pass:15': 8, 'red_chasm:7': 4 };
const q = quoteSpiceCollection(g);
assert.deepEqual(q.shared, [
{ territory: 'wind_pass', ecaz: 'ec', ally: 'al', amount: 3 },
]);
assert.equal(q.spice['wind_pass:15'], 6);
assert.equal(receipt(q, 'ec').collected, 4);
assert.equal(receipt(q, 'al').collected, 0);
conserved(g, q);
});
void test('each shared desert has its own amount and no automatic default is credited', () => {
const g = fixture();
for (const id of ['ec', 'al'])
army(g, id, { 'wind_pass:14': 1, 'red_chasm:7': 1 });
g.spice = { 'wind_pass:14': 1, 'red_chasm:7': 4 };
const q = quoteSpiceCollection(g);
assert.deepEqual(
new Map(q.shared.map((s) => [s.territory, s.amount])),
new Map([
['wind_pass', 1],
['red_chasm', 4],
]),
);
assert.equal(receipt(q, 'ec').balance, 10);
assert.equal(receipt(q, 'al').balance, 10);
conserved(g, q);
});
for (const unavailable of ['storm', 'noDeposit', 'zeroForces'] as const)
void test(`a partner with ${unavailable} capacity gets no shared entitlement`, () => {
const g = fixture();
army(g, 'ec', { 'wind_pass:14': 2 });
army(g, 'al', { 'wind_pass:15': unavailable === 'zeroForces' ? 0 : 2 });
g.spice = {
'wind_pass:14': 5,
'wind_pass:15': unavailable === 'noDeposit' ? 0 : 5,
};
if (unavailable === 'storm') g.storm = 15;
const q = quoteSpiceCollection(g);
assert.deepEqual(q.shared, []);
assert.equal(receipt(q, 'ec').collected, 4);
assert.equal(receipt(q, 'al').collected, 0);
conserved(g, q);
});
void test('each participant retains its own normal city rate, and co-occupied Arrakeen gives both rate three', () => {
const g = fixture();
army(g, 'ec', { 'wind_pass:14': 1, 'arrakeen:10': 1 });
army(g, 'al', { 'wind_pass:14': 1 });
g.spice = { 'wind_pass:14': 10 };
assert.equal(quoteSpiceCollection(g).shared[0].amount, 5);
army(g, 'al', { 'wind_pass:14': 1, 'arrakeen:10': 1 });
assert.equal(quoteSpiceCollection(g).shared[0].amount, 6);
});
void test('Ix cyborgs use three each while accompanying suboids retain their normal rate', () => {
const g = fixture('ixians');
army(g, 'ec', { 'wind_pass:14': 1 });
army(g, 'al', { 'wind_pass:14': 2 });
g.players[1].elites!.forces = { 'wind_pass:14': 1 };
g.players[1].elites!.reserves = 6;
g.spice = { 'wind_pass:14': 20 };
assert.equal(quoteSpiceCollection(g).shared[0].amount, 7);
army(g, 'al', { 'wind_pass:14': 2, 'arrakeen:10': 1 });
assert.equal(quoteSpiceCollection(g).shared[0].amount, 8);
});
void test('allied advisors do not pool; lone advisors release before normal collection', () => {
const g = shared(fixture('beneGesserit', true));
g.players[1].advisors = { wind_pass: {}, red_chasm: {} };
army(g, 'al', { 'wind_pass:14': 2, 'red_chasm:7': 1 });
g.spice['red_chasm:7'] = 3;
const q = quoteSpiceCollection(g);
assert.deepEqual(q.shared, []);
assert.deepEqual(q.released, [{ player: 'al', territory: 'red_chasm' }]);
assert.equal(receipt(q, 'ec').collected, 4);
assert.equal(receipt(q, 'al').collected, 2);
conserved(g, q);
});
void test('No-Field values zero, three and five have the same one-force collection capacity and remain concealed', () => {
let previous: ReturnType<typeof quoteSpiceCollection> | undefined;
for (const index of [0, 1, 2]) {
const g = fixture('richese');
army(g, 'ec', { 'wind_pass:14': 1 });
const ids = ['n0', 'n3', 'n5'];
g.players[1].noField = deployRicheseNoField(createRicheseNoField(ids), {
tokenId: ids[index],
controller: 'al',
location: { territory: 'wind_pass', sector: 14 },
});
g.spice = { 'wind_pass:14': 8 };
const before = structuredClone(g);
const q = quoteSpiceCollection(g);
assert.equal(q.shared[0].amount, 4);
if (previous) assert.deepEqual(q, previous);
previous = q;
assert.deepEqual(g, before);
Object.defineProperty(g.players[1].noField, 'tokens', {
get() {
throw Error('Private token inventory read');
},
});
assert.deepEqual(quoteSpiceCollection(g), q);
conserved(g, q);
}
});
void test('Advanced cancellation removes only Ecaz shared-city income, preserving solo income, ally income and all desert lots', () => {
const g = shared(fixture('emperor', true));
army(g, 'ec', {
'wind_pass:14': 2,
'arrakeen:10': 1,
'carthag:11': 1,
'tueks_sietch:5': 1,
});
army(g, 'al', { 'wind_pass:14': 2, 'arrakeen:10': 1, 'tueks_sietch:5': 1 });
const allowed = quoteSpiceCollection(g);
const canceled = quoteSpiceCollection(g, true);
assert.deepEqual(allowed.collectionBonus, {
owner: 'ec',
ally: 'al',
strongholds: ['arrakeen', 'tueks_sietch'],
amount: 3,
});
assert.deepEqual(canceled.collectionBonus, allowed.collectionBonus);
assert.equal(receipt(allowed, 'ec').strongholds, 5);
assert.equal(receipt(canceled, 'ec').strongholds, 2);
assert.equal(receipt(canceled, 'ec').balance, 12);
assert.equal(receipt(canceled, 'al').strongholds, 3);
assert.deepEqual(canceled.shared, allowed.shared);
assert.deepEqual(canceled.spice, allowed.spice);
conserved(g, canceled);
g.advanced = false;
assert.equal(quoteSpiceCollection(g, true).collectionBonus, null);
assert.equal(receipt(quoteSpiceCollection(g, true), 'ec').strongholds, 0);
});
void test('a shared stronghold with advisors grants no cancelable Ecaz bonus', () => {
const g = fixture('beneGesserit', true);
for (const id of ['ec', 'al']) army(g, id, { 'arrakeen:10': 1 });
g.players[1].advisors = { arrakeen: {} };
const q = quoteSpiceCollection(g, true);
assert.equal(q.collectionBonus, null);
assert.equal(receipt(q, 'ec').strongholds, 2);
assert.equal(receipt(q, 'al').strongholds, 0);
});
void test('ordinary non-allied collection retains its storm-order capped receipts', () => {
const g = fixture();
g.players[0].ally = null;
g.players[1].ally = null;
for (const id of ['ec', 'al']) army(g, id, { 'wind_pass:14': 2 });
g.spice = { 'wind_pass:14': 5 };
assert.deepEqual(
quoteSpiceCollection(g).receipts.map((r) => r.collected),
[4, 1, 0],
);
g.order = ['al', 'ec', 'en'];
assert.deepEqual(
quoteSpiceCollection(g).receipts.map((r) => r.collected),
[4, 1, 0],
);
assert.deepEqual(quoteSpiceCollection(g).shared, []);
});
void test('non-desert physical spice keys retain ordinary behavior rather than acquiring a new sharing rule', () => {
const g = fixture();
for (const id of ['ec', 'al']) army(g, id, { 'shield_wall:8': 2 });
g.spice = { 'shield_wall:8': 5 };
const q = quoteSpiceCollection(g);
assert.deepEqual(q.shared, []);
assert.deepEqual(
q.receipts.map((r) => r.collected),
[4, 1, 0],
);
});
void test('phase-six aid refund prequote preserves shared lots as unresolved allocation and Ix opening still defers collection', () => {
const g = shared();
g.phase = 6;
g.aid = { ec: { recipient: 'al', amount: 3 } };
const q = quoteBattlePhaseAdvance(g);
assert.equal(q.collection!.shared[0].amount, 5);
assert.equal(receipt(q.collection!, 'ec').balance, 13);
assert.equal(receipt(q.collection!, 'al').balance, 10);
g.expansions.push('ix');
assert.equal(quoteBattlePhaseAdvance(g).collection, null);
});
void test('invalid roster, stance, typed custody and arithmetic reject purely before any receipt exists', () => {
const corruptions: ((g: Game) => void)[] = [
(g) => {
g.players[1].ally = null;
},
(g) => {
g.players[2].faction = 'emperor';
},
(g) => {
g.players[0].advisors = { missing: {} };
},
(g) => {
g.players[0].advisors = { wind_pass: { lockedTurn: -1 } };
},
(g) => {
g.players[1].elites = {
forces: { 'wind_pass:14': 3 },
reserves: 0,
tanks: 0,
revived: 0,
};
},
(g) => {
g.players[0].forces['wind_pass:14'] = Number.MAX_SAFE_INTEGER;
},
(g) => {
g.players[0].spice = -1;
},
(g) => {
g.spice['wind_pass:14'] = Number.MAX_SAFE_INTEGER + 1;
},
(g) => {
g.players[0].forces['wind_pass:14'] = 3_000_000_000_000_000;
g.players[1].forces['wind_pass:14'] = 3_000_000_000_000_000;
},
];
for (const corrupt of corruptions) {
const g = shared();
corrupt(g);
const before = structuredClone(g);
assert.throws(() => quoteSpiceCollection(g), BoardResolutionError);
assert.deepEqual(g, before);
}
});
void test('repeated quoting is deterministic and never reads hands, leaders, predictions or reserve inventories', () => {
const g = shared();
const expected = quoteSpiceCollection(g);
for (const p of g.players)
for (const key of ['hand', 'leaders', 'prediction', 'reserves'])
Object.defineProperty(p, key, {
get() {
throw Error(`Private ${key} read`);
},
});
assert.deepEqual(quoteSpiceCollection(g), expected);
assert.deepEqual(quoteSpiceCollection(g), expected);
});