Files
Dune/tests/spice-banker.test.ts
T
Quintine 1380271bb6 Connect Spice Banker sealed battle spending and saved settlement
Prototype one through three own spice as a separate sealed battle commitment. Reserve funds alongside actual force support, add strength only for the surviving skilled leader, and settle once through existing versioned writes. Apply the documented card/core-rule interpretation: payment survives leader death or loss, except for the sole successful traitor caller. Keep printed disc strength and bounty unchanged. Normal income awaits its deferred-custody ruling; combined modules remain gated.

Validation: 54 focused tests; typecheck, lint, 4490 offline tests, production build and 43 HTTP tests passed. Independent rules, privacy, persistence and UI review completed. SQLite tests cover reserved-spice diversion, concurrent settlement, traitor waiver and legacy plans. Browser exercised a three-spice commitment, pending refresh, actual +3 strength and payment, and restored private cards and the next decision. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not use Banker spending.

Following another reported outage, the server remained healthy. Preserved all 461 opening rooms and original seat/recovery rows (495 rooms after QA/HTTP). SQLite integrity and foreign keys passed. A fresh online backup captured all 495 rooms, and browser refresh restored the QA seat. No restart or reset was needed.

Verified source tree: 208bf9324ab994b5e59ed50d78998b4dbf8810b3b6799b1a577ef3b84479335f
2026-09-13 20:45:42 +10:00

198 lines
6.7 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { spiceBankerGame } from './spice-banker-fixture';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { validateLeaderSkills } from '../game/leader-skills';
function reject(g: Game, owner: string, action: Action) {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, owner, action));
assert.equal(JSON.stringify(g), before);
}
function conserve(g: Game) {
validateLeaderSkills(g.leaderSkills!, g.players);
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
if (p.elites)
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
5,
);
}
}
const plan = (g: Game, bankerSpice?: unknown): Action => ({
type: 'battlePlan',
leader: 'emperor-0',
dial: 0,
bankerSpice,
weapon: g.players[0].hand[0].id,
});
function settle(
g: Game,
opponent: Action = { type: 'battlePlan', leader: 'guild-1', dial: 5 },
calls: [boolean, boolean] = [false, false],
) {
g = applyAction(g, 'd', opponent);
g = applyAction(g, 'a', { type: 'traitorCall', call: calls[0] });
return applyAction(g, 'd', { type: 'traitorCall', call: calls[1] });
}
function traitor(g: Game, owner: number, identity: string) {
const p = g.players[owner];
if (p.traitors.includes(identity)) return;
const zone = [g.traitorReserve!, ...g.players.map((p) => p.traitors)].find(
(zone) => zone.includes(identity),
)!;
assert.ok(zone);
const old = p.traitors[0];
zone[zone.indexOf(identity)] = old;
p.traitors[0] = identity;
}
void test('Spice Banker spends one through three on win or loss and changes score without changing printed disc values', () => {
for (const amount of [1, 2, 3]) {
const before = spiceBankerGame();
const done = settle(applyAction(before, 'a', plan(before, amount)));
assert.equal(done.players[0].spice, 20 - amount);
assert.equal(done.lastBattleContext?.winner, amount === 1 ? 'd' : 'a');
assert.equal(
done.players[0].leaders[0].strength,
before.players[0].leaders[0].strength,
);
assert.ok(
done.log.some((l) => l.text.includes(`gained ${amount} battle strength`)),
);
conserve(done);
}
});
void test('Banker own spice and Advanced force support cannot spend the same funds', () => {
const g = spiceBankerGame(true);
g.players[0].spice = 5;
reject(g, 'a', { ...plan(g, 3), support: 3, dial: 3 });
const sealed = applyAction(g, 'a', { ...plan(g, 3), support: 2, dial: 2 });
assert.equal(sealed.players[0].spice, 5);
const done = settle(sealed, {
type: 'battlePlan',
leader: 'guild-1',
dial: 0,
});
assert.equal(done.players[0].spice, 0);
conserve(done);
});
void test('killed Bankers lose the bonus and still pay, while a sole successful traitor caller pays nothing', () => {
const g = spiceBankerGame();
const index = g.deck.findIndex((c) => c.kind === 'projectile');
const weapon = g.deck.splice(index, 1)[0];
g.players[1].hand.push(weapon);
const dead = settle(applyAction(g, 'a', plan(g, 3)), {
type: 'battlePlan',
leader: 'guild-1',
dial: 0,
weapon: weapon.id,
});
assert.equal(dead.players[0].leaders[0].dead, true);
assert.equal(dead.players[0].spice, 17);
assert.ok(!dead.log.some((l) => l.text.includes('gained 3 battle strength')));
conserve(dead);
for (const calls of [
[true, false],
[false, true],
[true, true],
] as [boolean, boolean][]) {
const g = spiceBankerGame();
traitor(g, 0, 'guild-1');
traitor(g, 1, 'emperor-0');
const done = settle(
applyAction(g, 'a', plan(g, 3)),
{ type: 'battlePlan', leader: 'guild-1', dial: 0 },
calls,
);
assert.equal(done.players[0].spice, calls[0] && !calls[1] ? 23 : 17);
assert.equal(done.players[0].leaders[0].dead, calls[1]);
conserve(done);
}
});
void test('sealed Banker commitments stay private and reject changed funds, quantities or leader custody', () => {
const g = spiceBankerGame();
g.players[0].spice = 3;
const sealed = applyAction(g, 'a', plan(g, 3));
assert.equal(viewGame(sealed, 'a').battle?.plans.a?.bankerSpice, 3);
assert.ok(!JSON.stringify(viewGame(sealed, 'd')).includes('bankerSpice'));
assert.deepEqual(
normalizeAutomaticGame(JSON.parse(JSON.stringify(sealed))),
sealed,
);
for (const corrupt of [
(g: Game) => {
g.discoveryEnabled = true;
},
(g: Game) => {
g.battle!.plans.a.bankerSpice = 4;
},
(g: Game) => {
g.players[0].spice = 2;
},
(g: Game) => {
g.battle!.plans.a.leader = 'emperor-1';
},
]) {
const bad = structuredClone(sealed);
corrupt(bad);
assert.throws(() => viewGame(bad, 'a'));
assert.throws(() => normalizeAutomaticGame(bad));
reject(bad, 'd', { type: 'battlePlan', leader: 'guild-1', dial: 0 });
}
for (const value of [-1, 4, 1.5, '3', true, null])
reject(g, 'a', plan(g, value));
reject(g, 'a', { ...plan(g, 1), leader: 'emperor-1' });
});
void test('captured Bankers spend captor funds, and legacy omitted commitments preserve ordinary plans', () => {
const g = spiceBankerGame(false, true);
const done = settle(
applyAction(g, 'a', { ...plan(g, 3), leader: 'guild-0' }),
{ type: 'battlePlan', leader: 'guild-1', dial: 0 },
);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[1].spice, 20);
assert.equal(done.players[1].leaders[0].capturedBy, undefined);
conserve(done);
const legacy = spiceBankerGame();
const old = applyAction(legacy, 'a', plan(legacy));
assert.equal(old.battle!.plans.a.bankerSpice, undefined);
const resumed = settle(JSON.parse(JSON.stringify(old)));
assert.equal(resumed.players[0].spice, 20);
assert.ok(
!resumed.log.some((l) => l.automatic?.name === 'Spice Banker payment'),
);
conserve(resumed);
});
void test('all four AI profiles have funded Banker battle plans using their private view', () => {
for (const difficulty of DIFFICULTIES) {
const g = spiceBankerGame();
g.players[0].spice = 2;
const view = viewGame(g, 'a');
view.players[0].bot = difficulty;
const actions = botActions(view).filter(
(a) => a.type === 'battlePlan' && Number(a.bankerSpice) > 0,
);
assert.ok(actions.length, difficulty);
for (const action of actions) {
assert.ok(Number(action.bankerSpice) <= 2);
const next = applyAction(g, 'a', action);
assert.equal(next.players[0].spice, 2);
conserve(next);
}
}
});