Files
Dune/tests/bot-transport-budget.test.ts
T
2026-09-08 07:37:52 +10:00

115 lines
3.6 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { territory } from '../game/board';
function fixture(fremen = false) {
const g = createGame(
'TRANSPORT',
newPlayer('p', 'Mover', fremen ? 'fremen' : 'guild'),
fremen,
);
g.players.push(newPlayer('q', 'Ally', fremen ? 'guild' : 'emperor'));
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
active: 'p',
order: ['p', 'q'],
movementRemaining: ['p', 'q'],
storm: 18,
});
for (const p of g.players) {
p.forces = {};
p.hand = [];
p.spice = 0;
}
if (fremen) {
g.players[0].ally = 'q';
g.players[1].ally = 'p';
} else {
g.players[0].forces = { 'red_chasm:7': 5 };
g.players[0].reserves = 15;
}
return g;
}
const transports = (g: Game) =>
botActions(viewGame(g, 'p')).filter((a) => a.type === 'guildShip');
const price = (to: unknown, n: unknown) =>
Math.ceil(
(Number(n) * (territory(String(to)).type === 'stronghold' ? 1 : 2)) / 2,
);
void test('Guild transport candidates use rounded total cost at every budget and preserve affordable target order', () => {
for (const level of DIFFICULTIES) {
const g = fixture();
g.players[0].bot = level;
g.players[0].spice = 1000;
const rich = transports(g);
assert.ok(rich.length);
for (const budget of [0, 1, 2, 3, 4, 5, 8]) {
g.players[0].spice = budget;
const actual = transports(g);
assert.deepEqual(
actual,
rich.filter((a) => price(a.territory, a.amount) <= budget),
);
for (const a of actual) {
const result = applyAction(g, 'p', a);
assert.equal(
result.players[0].spice,
budget - price(a.territory, a.amount),
);
assert.equal(result.players[0].shipped, true);
}
}
}
});
void test('allied Fremen paid cross-shipping consumes the explicit shared budget and remains distinct from free reinforcements', () => {
for (const level of DIFFICULTIES) {
const g = fixture(true);
g.players[0].bot = level;
assert.deepEqual(transports(g), []);
g.players[1].spice = 3;
g.aid.q = { recipient: 'p', amount: 3 };
const offered = transports(g);
assert.ok(offered.length);
for (const a of offered) {
const cost = price(a.territory, a.amount);
assert.ok(cost > 0 && cost <= 3);
const result = applyAction(g, 'p', a);
assert.equal(result.players[0].spice, 0);
// The pledge is already escrowed; spend it without charging the donor twice.
assert.equal(result.players[1].spice, 3);
assert.equal(result.aid.q.amount, 3 - cost);
}
const free = botActions(viewGame(g, 'p')).filter((a) => a.type === 'ship');
assert.ok(free.length);
for (const a of free)
assert.equal(applyAction(g, 'p', a).players[1].spice, 3);
g.aid.q = { recipient: 'q', amount: 3 };
assert.deepEqual(transports(g), []);
}
});
void test('changing unpledged opposing resources and private cards never changes transport offers', () => {
for (const level of DIFFICULTIES) {
const g = fixture(true);
g.players[0].bot = level;
g.players[0].spice = 4;
const before = transports(g),
snapshot = structuredClone(g);
g.players[1].spice = 99999;
g.players[1].hand = [{ id: 'hidden', name: 'Hidden', kind: 'lasgun' }];
assert.deepEqual(transports(g), before);
assert.deepEqual(snapshot.players[0], g.players[0]);
}
});