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

176 lines
5.8 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
quotePhaseResources,
quoteAidRefunds,
PhaseResourceError,
} from '../game/phase-resource-quote';
import { createTechTokens } from '../game/tech-tokens';
import {
quoteBattlePhaseAdvance,
BoardResolutionError,
} from '../game/board-resolution-quote';
import { applyAction, createGame, newPlayer } from '../game/engine';
import { createAmbassadors } from '../game/ecaz-ambassadors';
const fixture = () => ({
phase: 5,
players: [
{ id: 'a', spice: 10 },
{ id: 'b', spice: 20 },
],
techTokens: createTechTokens([
{ id: 'a', faction: 'ixians' },
{ id: 'b', faction: 'tleilaxu' },
]),
aid: { a: { recipient: 'b', amount: 3 }, b: { recipient: 'a', amount: 2 } },
});
void test('tech income precedes aid refunds and aggregate balances include both once', () => {
const g = fixture();
g.techTokens.heighliners.spice = 2;
const before = structuredClone(g);
const q = quotePhaseResources(g);
assert.deepEqual(q.credits, [
{ kind: 'tech', token: 'heighliners', player: 'a', amount: 2, balance: 12 },
{ kind: 'aid', player: 'a', amount: 3, balance: 15 },
{ kind: 'aid', player: 'b', amount: 2, balance: 22 },
]);
assert.deepEqual(q.tokenResets, ['heighliners']);
assert.deepEqual(q.balances, { a: 15, b: 22 });
assert.equal(q.clearAid, true);
assert.deepEqual(g, before);
q.balances.a = 0;
q.refunds[0].amount = 100;
assert.deepEqual(g, before);
});
void test('only current technology and phase3/5/6 escrow settle; other phases preserve escrow', () => {
for (let phase = 0; phase <= 8; phase++) {
const g = fixture();
g.phase = phase;
g.techTokens.production = { owner: 'a', spice: 3 };
g.techTokens.heighliners.spice = 2;
g.techTokens.axlotl.spice = 1;
const q = quotePhaseResources(g);
assert.deepEqual(
q.credits.filter((c) => c.kind === 'tech').map((c) => c.amount),
phase === 2 ? [3] : phase === 4 ? [1] : phase === 5 ? [2] : [],
);
assert.equal(q.clearAid, [3, 5, 6].includes(phase));
assert.equal(q.refunds.length, q.clearAid ? 2 : 0);
}
});
void test('refunds do not demand current recipient custody, mutual alliance or a positive pledge', () => {
const g = fixture();
g.aid.a = { recipient: 'former-absent-ally', amount: 0 };
assert.deepEqual(quoteAidRefunds(g.players, g.aid), [
{ player: 'a', amount: 0, balance: 10 },
{ player: 'b', amount: 2, balance: 22 },
]);
});
void test('negative, missing donor, malformed and cumulative-overflow payments reject immutably', () => {
const mutations = [
(g: ReturnType<typeof fixture>) => {
g.aid.a.amount = -1;
},
(g: ReturnType<typeof fixture>) => {
g.aid.a.amount = 0.5;
},
(g: ReturnType<typeof fixture>) => {
g.players = g.players.filter((p) => p.id !== 'a');
},
(g: ReturnType<typeof fixture>) => {
g.aid = null as never;
},
(g: ReturnType<typeof fixture>) => {
g.techTokens.heighliners.owner = 'absent';
},
(g: ReturnType<typeof fixture>) => {
g.techTokens.heighliners.spice = -1;
},
(g: ReturnType<typeof fixture>) => {
g.techTokens.heighliners = null as never;
},
(g: ReturnType<typeof fixture>) => {
g.players[0].spice = Number.MAX_SAFE_INTEGER - 3;
g.techTokens.heighliners.spice = 1;
},
];
for (const change of mutations) {
const g = fixture();
change(g);
const before = structuredClone(g);
assert.throws(() => quotePhaseResources(g), PhaseResourceError);
assert.deepEqual(g, before);
}
});
void test('an unrelated technology phase and already accrued turn marker are not new settlement prerequisites', () => {
const g = fixture();
g.phase = 6;
g.techTokens.axlotl = null as never;
assert.equal(quotePhaseResources(g).credits.length, 2);
g.phase = 5;
g.techTokens.heighliners.spice = 2;
g.techTokens.heighliners.triggeredTurn = 1;
assert.equal(quotePhaseResources(g).balances.a, 15);
g.techTokens.heighliners.spice = 0;
assert.deepEqual(quotePhaseResources(g).tokenResets, []);
});
void test('battle phase advance reuses refund proof without collecting before an Ix phase opening', () => {
const g = {
advanced: false,
storm: 18,
order: ['a', 'b'],
players: [
{
id: 'a',
faction: 'emperor' as const,
ally: null,
forces: {},
spice: 10,
},
{
id: 'b',
faction: 'fremen' as const,
ally: null,
forces: {},
spice: 20,
},
],
spice: {},
expansions: ['ix'],
aid: { a: { recipient: 'b', amount: 3 } },
};
const q = quoteBattlePhaseAdvance(g);
assert.deepEqual(q.refunds, quoteAidRefunds(g.players, g.aid));
assert.equal(q.collection, null);
g.aid.a.amount = -1;
assert.throws(() => quoteBattlePhaseAdvance(g), BoardResolutionError);
});
void test('genuine end-of-Revival settles Axlotl before Ecaz placement and completion does not repay it', () => {
let g = createGame('PHASERESOURCE', newPlayer('e', 'Ecaz', 'ecaz'));
g.players.push(newPlayer('p', 'Emperor', 'emperor'));
Object.assign(g, {
status: 'playing',
phase: 4,
turn: 2,
order: ['e', 'p'],
storm: 18,
ecazAmbassadors: createAmbassadors(() => 0),
});
for (const p of g.players) {
p.spice = 10;
p.hand = [];
p.forces = {};
}
g.techTokens = createTechTokens(g.players);
g.techTokens.axlotl = { owner: 'p', spice: 3, triggeredTurn: 2 };
const q = quotePhaseResources(g);
assert.equal(q.balances.p, 13);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'ecazPlacement');
assert.equal(g.players[1].spice, 13);
assert.equal(g.techTokens!.axlotl.spice, 0);
g = applyAction(g, 'e', { type: 'decision', decline: true });
assert.equal(g.phase, 5);
assert.equal(g.players[1].spice, 13);
});