Files
Dune/tests/board-resolution-quote.test.ts

342 lines
12 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
quoteBattleBoard,
quoteSpiceCollection,
quoteBattleBoardContinuation,
quoteBattlePhaseAdvance,
BoardResolutionError,
type BoardContext,
} from '../game/board-resolution-quote';
import {
createGame,
newPlayer,
applyAction,
battles,
type Game,
} from '../game/engine';
const fixture = () => {
const g = createGame(
'BOARDQUOTE',
newPlayer('a', 'Atreides', 'atreides'),
true,
);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('b', 'BG', 'beneGesserit'),
);
Object.assign(g, {
status: 'playing',
phase: 6,
turn: 2,
storm: 18,
order: ['a', 'e', 'b'],
active: 'a',
phaseOpening: null,
});
for (const p of g.players)
Object.assign(p, {
hand: [],
traitors: [],
traitorChoices: [],
forces: {},
reserves: 20,
spice: 10,
});
return g;
};
void test('battle quote settles only abandoned advisor stances without mutating or reading private hands', () => {
const g = fixture();
g.players[2].forces = { 'red_chasm:7': 2, 'arrakeen:10': 1 };
g.players[2].advisors = { red_chasm: {}, arrakeen: {}, carthag: {} };
g.players[0].forces = { 'arrakeen:10': 1 };
const before = structuredClone(g);
const q = quoteBattleBoard(g);
assert.deepEqual(q.released, [
{ player: 'b', territory: 'red_chasm' },
{ player: 'b', territory: 'carthag' },
]);
assert.deepEqual(q.battles, []);
assert.deepEqual(g, before);
for (const p of g.players)
for (const key of ['hand', 'traitors', 'spice', 'leaders'])
Object.defineProperty(p, key, {
get() {
throw Error('Private data read');
},
});
assert.deepEqual(quoteBattleBoard(g), q);
});
void test('actual battles applies the quoted advisor releases once and preserves occupied advisors', () => {
const g = fixture();
g.players[2].forces = { 'red_chasm:7': 2 };
g.players[2].advisors = { red_chasm: {}, arrakeen: {} };
const q = quoteBattleBoard(g);
assert.deepEqual(battles(g), q.battles);
assert.deepEqual(g.players[2].advisors, {});
assert.deepEqual(battles(g), []);
});
void test('battle order excludes allies, polar sink and storm-separated forces, including empty saved location keys', () => {
const g = fixture();
g.storm = 6;
g.players[0].forces = {
'the_minor_erg:5': 2,
'the_minor_erg:7': 0,
'polar_sink:0': 2,
};
g.players[1].forces = { 'the_minor_erg:7': 2, 'polar_sink:0': 2 };
assert.deepEqual(quoteBattleBoard(g).battles, []);
g.storm = 18;
assert.deepEqual(quoteBattleBoard(g).battles, [
{ territory: 'the_minor_erg', attacker: 'a', defender: 'e' },
]);
g.players[0].ally = 'e';
g.players[1].ally = 'a';
assert.deepEqual(quoteBattleBoard(g).battles, []);
});
void test('Advanced whole Ecaz armies cannot recruit storm-isolated allies or split into component battles; Basic is unchanged', () => {
const ecaz = newPlayer('ec', 'Ecaz', 'ecaz');
const ally = newPlayer('al', 'Guild', 'guild');
const enemy = newPlayer('en', 'Emperor', 'emperor');
ecaz.ally = ally.id;
ally.ally = ecaz.id;
ecaz.forces = { 'imperial_basin:9': 1, 'imperial_basin:11': 1 };
ally.forces = { 'imperial_basin:11': 2 };
const context: BoardContext = {
advanced: true, storm: 10, order: ['ec', 'al', 'en'], players: [ecaz, ally, enemy],
};
for (const sector of [9, 11]) {
enemy.forces = { [`imperial_basin:${sector}`]: 2 };
assert.deepEqual(quoteBattleBoard(context).battles, [],
'All coexisting fighters must be clear and connected before the joint battle is offered.');
}
enemy.forces = { 'imperial_basin:9': 2 };
assert.deepEqual(quoteBattleBoard({ ...context, advanced: false }).battles,
[{ territory: 'imperial_basin', attacker: 'ec', defender: 'en' }]);
ecaz.forces = { 'imperial_basin:9': 2 };
ally.forces = { 'imperial_basin:9': 2 };
const clear = quoteBattleBoard(context).battles;
assert.equal(clear.length, 1, 'A clear joint army is one battle, not two faction battles.');
assert.ok(clear[0].coalition?.includes(ecaz.id) && clear[0].coalition.includes(ally.id),
'A represented co-side must retain membership for voluntary advisor conversion.');
assert.deepEqual(quoteBattleBoard({ ...context, storm: 9 }).battles, [],
'A stronghold-safe army in storm still cannot battle.');
});
void test('the moving stronghold uses its pointer sector for storm obstruction and the declared player order', () => {
const g = fixture();
g.mobileStronghold = { location: 'red_chasm:7' };
g.players[0].forces = { 'hidden_mobile_stronghold:0': 1 };
g.players[1].forces = { 'hidden_mobile_stronghold:0': 2 };
g.order = ['e', 'a', 'b'];
assert.deepEqual(quoteBattleBoard(g).battles, [
{ territory: 'hidden_mobile_stronghold', attacker: 'e', defender: 'a' },
]);
g.storm = 7;
assert.deepEqual(quoteBattleBoard(g).battles, []);
});
void test('collection uses storm order and shared remaining spice, with city rate and Advanced income separated', () => {
const g = fixture();
g.players[0].forces = { 'arrakeen:10': 1, 'red_chasm:7': 2 };
g.players[1].forces = { 'red_chasm:7': 2 };
g.spice = { 'red_chasm:7': 9 };
const before = structuredClone(g),
q = quoteSpiceCollection(g);
assert.deepEqual(q.receipts, [
{ player: 'a', strongholds: 2, collected: 6, desert: 6, balance: 18 },
{ player: 'e', strongholds: 0, collected: 3, desert: 3, balance: 13 },
{ player: 'b', strongholds: 0, collected: 0, desert: 0, balance: 10 },
]);
assert.deepEqual(q.deposits, [
{ player: 'a', location: 'red_chasm:7', amount: 6 },
{ player: 'e', location: 'red_chasm:7', amount: 3 },
]);
assert.equal(q.spice['red_chasm:7'], 0);
assert.deepEqual(g, before);
g.order = ['e', 'a', 'b'];
assert.deepEqual(
quoteSpiceCollection(g).receipts.map((r) => r.collected),
[4, 5, 0],
);
assert.deepEqual(quoteSpiceCollection(g).deposits, [
{ player: 'e', location: 'red_chasm:7', amount: 4 },
{ player: 'a', location: 'red_chasm:7', amount: 5 },
]);
});
void test('collection counts a concealed marker as one without inspecting its secret token or reserve value', () => {
const g = fixture();
g.players[0] = newPlayer('a', 'Richese', 'richese');
g.players[0].spice = 10;
g.players[0].noField = {
deployed: { location: { territory: 'red_chasm', sector: 7 } },
} as NonNullable<Game['players'][number]['noField']>;
Object.defineProperty(g.players[0].noField, 'tokens', {
get() {
throw Error('Hidden tokens read');
},
});
Object.defineProperty(g.players[0], 'reserves', {
get() {
throw Error('Private reserves read');
},
});
g.spice = { 'red_chasm:7': 5 };
assert.equal(quoteSpiceCollection(g).receipts[0].collected, 2);
});
void test('Ixian cyborgs collect three each while suboids use the current ordinary rate', () => {
const g = fixture();
g.players[0] = newPlayer('a', 'Ix', 'ixians');
Object.assign(g.players[0], {
spice: 10,
forces: { 'red_chasm:7': 3 },
elites: { forces: { 'red_chasm:7': 1 }, reserves: 3, tanks: 0, revived: 0 },
});
g.spice = { 'red_chasm:7': 10 };
assert.equal(quoteSpiceCollection(g).receipts[0].collected, 7);
g.players[0].forces['arrakeen:10'] = 1;
assert.equal(quoteSpiceCollection(g).receipts[0].collected, 9);
});
void test('occupied advisors cannot collect, while automatic solo fighters can and storm losses collect nothing', () => {
const g = fixture();
g.players[2].forces = { 'red_chasm:7': 2, 'arrakeen:10': 1 };
g.players[2].advisors = { red_chasm: {}, arrakeen: {} };
g.players[0].forces = { 'arrakeen:10': 1 };
g.spice = { 'red_chasm:7': 9, 'arrakeen:10': 5 };
const q = quoteSpiceCollection(g);
assert.equal(q.receipts[2].collected, 4);
assert.equal(q.receipts[2].strongholds, 0);
g.storm = 7;
assert.equal(quoteSpiceCollection(g).receipts[2].collected, 0);
});
void test('malformed board and collection arithmetic reject without any state mutation', () => {
const base = fixture();
base.players[0].forces = { 'red_chasm:7': 2 };
base.spice = { 'red_chasm:7': 5 };
for (const corrupt of [
(g: Game) => {
g.order = ['a', 'a', 'b'];
},
(g: Game) => {
g.players[0].forces = { 'red_chasm:8': 2 };
},
(g: Game) => {
g.players[0].forces['red_chasm:7'] = -1;
},
(g: Game) => {
g.spice['red_chasm:7'] = -1;
},
(g: Game) => {
g.players[0].spice = Number.MAX_SAFE_INTEGER;
},
]) {
const g = structuredClone(base);
corrupt(g);
const before = structuredClone(g);
assert.throws(() => quoteSpiceCollection(g), BoardResolutionError);
assert.deepEqual(g, before);
}
});
void test('actual phase advancement consumes the collection quote once and restores across JSON', () => {
let g = fixture();
g.advanced = false;
g.phase = 5;
g.active = 'b';
g.movementRemaining = ['b'];
for (const p of g.players) {
p.shipped = true;
p.moved = 1;
}
g.players[0].forces = { 'arrakeen:10': 1, 'red_chasm:7': 2 };
g.spice = { 'red_chasm:7': 8 };
const q = quoteSpiceCollection(g);
g = applyAction(g, 'b', { type: 'endMovement' });
assert.equal(g.phase, 7);
assert.deepEqual(g.spice, q.spice);
assert.deepEqual(
g.players.map((p) => p.spice),
q.receipts.map((r) => r.balance),
);
const saved = JSON.parse(JSON.stringify(g));
assert.deepEqual(
saved.players.map((p: { spice: number }) => p.spice),
q.receipts.map((r) => r.balance),
);
assert.equal(
saved.log.filter((l: { text: string }) =>
l.text.includes('collected 6 spice'),
).length,
1,
);
});
void test('repeated board and collection quotes perform no random draw or event allocation', (t) => {
const g = fixture(),
before = structuredClone(g);
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('RNG');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('event');
});
for (let i = 0; i < 10; i++) {
quoteBattleBoard(g);
quoteSpiceCollection(g);
}
assert.deepEqual(g, before);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
void test('battle completion quotes refunds before collection without mutating escrow', () => {
const g = fixture();
g.players[0].forces = { 'arrakeen:10': 1, 'red_chasm:7': 2 };
g.spice = { 'red_chasm:7': 8 };
g.aid = { a: { recipient: 'b', amount: 4 } };
const before = structuredClone(g);
const q = quoteBattleBoardContinuation(g);
assert.deepEqual(q.board.battles, []);
assert.deepEqual(q.phase?.refunds, [{ player: 'a', amount: 4, balance: 14 }]);
assert.equal(q.phase?.collection?.receipts[0].balance, 22);
assert.deepEqual(g, before);
});
void test('remaining battles, CHOAM market and Ix opening stop at their decision boundaries', () => {
const g = fixture();
g.players[0].forces = { 'arrakeen:10': 1 };
g.players[1].forces = { 'arrakeen:10': 1 };
g.aid = { missing: { recipient: 'a', amount: 1 } };
assert.equal(quoteBattleBoardContinuation(g).phase, null);
g.players[1].forces = {};
g.players[1].faction = 'choam';
assert.equal(quoteBattleBoardContinuation(g).phase, null);
assert.throws(() => quoteBattlePhaseAdvance(g), BoardResolutionError);
g.aid = {};
g.expansions = ['ix'];
g.spice = { invalid: -1 };
assert.equal(quoteBattlePhaseAdvance(g).collection, null);
g.expansions = [];
assert.throws(() => quoteBattlePhaseAdvance(g), BoardResolutionError);
});
void test('invalid aid or refund plus collection overflow rejects before automatic effects', () => {
for (const corrupt of [
(g: Game) => {
g.aid = { missing: { recipient: 'a', amount: 1 } };
},
(g: Game) => {
g.aid = { a: { recipient: 'b', amount: -1 } };
},
(g: Game) => {
g.players[0].spice = Number.MAX_SAFE_INTEGER;
g.aid = { a: { recipient: 'b', amount: 1 } };
},
(g: Game) => {
g.players[0].spice = Number.MAX_SAFE_INTEGER - 2;
g.aid = { a: { recipient: 'b', amount: 1 } };
g.players[0].forces = { 'arrakeen:10': 1 };
},
]) {
const g = fixture();
corrupt(g);
const before = structuredClone(g);
assert.throws(() => quoteBattleBoardContinuation(g), BoardResolutionError);
assert.deepEqual(g, before);
}
});