342 lines
12 KiB
TypeScript
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);
|
|
}
|
|
});
|