Files
Dune/tests/spice-banker-controls.test.tsx
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

219 lines
6.6 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { SpiceBankerControl } from '../components/spice-banker';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
applyAction,
viewGame,
type GameView,
type Plan,
} from '../game/engine';
import { spiceBankerGame } from './spice-banker-fixture';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
context,
);
},
});
const { PrivateBattlePlan } = await import('../components/private-battle-plan');
const { RevealedBattle } = await import('../components/revealed-battle');
aliases.deregister();
function renderControl(
game: GameView,
selectedLeader: string,
ownSupport: number,
value: number,
) {
return renderToStaticMarkup(
createElement(SpiceBankerControl, {
game,
selectedLeader,
ownSupport,
value,
onChange() {},
}),
);
}
void test('Spice Banker renders a bounded separate own-spice commitment only for the controlled living skilled disc', () => {
const native = spiceBankerGame();
native.players[0].spice = 4;
const view = viewGame(native, 'a');
const html = renderControl(view, 'emperor-0', 2, 2);
assert.match(html, /aria-label="Spice Banker battle commitment"/);
assert.match(html, /id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/);
assert.match(
html,
/Plan funding: 2 own spice for force support \+ 2 committed to Spice Banker = 4 own spice total/,
);
assert.match(
html,
/Each committed spice adds 1 strength if this skilled leader survives/,
);
assert.match(html, /commitment goes to the Bank after a win or loss/);
assert.match(
html,
/sole winner through a traitor call spends no Battle Plan spice/,
);
assert.equal(renderControl(view, 'emperor-1', 0, 3), '');
const faceUp = structuredClone(view);
faceUp.leaderSkills!.assignments.find(
(assignment) => assignment.skill === 'spice-banker',
)!.faceUp = true;
assert.equal(renderControl(faceUp, 'emperor-0', 0, 3), '');
const dead = structuredClone(view);
dead.players
.find((player) => player.id === 'a')!
.leaders.find((leader) => leader.id === 'emperor-0')!.dead = true;
assert.equal(renderControl(dead, 'emperor-0', 0, 3), '');
const foreign = structuredClone(view);
foreign.leaderSkills!.assignments.find(
(assignment) => assignment.skill === 'spice-banker',
)!.controller = 'd';
assert.equal(renderControl(foreign, 'emperor-0', 0, 3), '');
const combinedModule = structuredClone(view);
combinedModule.expansions = ['ix'];
assert.equal(renderControl(combinedModule, 'emperor-0', 0, 3), '');
const capturedGame = spiceBankerGame(false, true);
capturedGame.players[0].spice = 3;
const captive = viewGame(capturedGame, 'a');
assert.match(
renderControl(captive, 'guild-0', 0, 3),
/Spice Banker commitment/,
);
});
void test('full-plan inspection and public reveal show the exact commitment without leaking it to another private viewer', () => {
const plan: Plan = {
dial: 1,
support: 0,
bankerSpice: 3,
leader: 'emperor-0',
weapon: null,
defense: null,
};
const inspected = {
me: 'atreides',
players: [
{ id: 'atreides', name: 'Atreides' },
{ id: 'emperor', name: 'Emperor' },
{ id: 'observer', name: 'Observer' },
],
allLeaders: [],
battle: {
revealed: false,
fullPlan: { owner: 'atreides', target: 'emperor' },
fullPlanInsight: { target: 'emperor', plan, cards: [] },
},
} as unknown as GameView;
const privateHtml = renderToStaticMarkup(
createElement(PrivateBattlePlan, { game: inspected }),
);
assert.match(privateHtml, /Spice Banker commitment<\/dt><dd>3<\/dd>/);
assert.equal(
renderToStaticMarkup(
createElement(PrivateBattlePlan, {
game: {
...inspected,
me: 'observer',
},
}),
),
'',
);
const revealed = {
...inspected,
advanced: false,
me: 'observer',
battle: {
attacker: 'emperor',
defender: 'atreides',
revealed: true,
plans: {
emperor: plan,
atreides: {
dial: 0,
support: 0,
leader: null,
weapon: null,
defense: null,
},
},
cards: [],
ownForces: null,
stoneBurner: {},
poisonTooth: {},
lateDefense: {},
native: null,
nativeBattleStrength: 0,
},
} as unknown as GameView;
const publicHtml = renderToStaticMarkup(
createElement(RevealedBattle, { game: revealed }),
);
assert.match(publicHtml, /Spice Banker: 3 spice committed/);
assert.equal(
(publicHtml.match(/Spice Banker:/g) ?? []).length,
1,
'zero commitments stay absent from public reveal',
);
});
void test('all four bot profiles reserve only remaining own spice and emit legal skilled plans', () => {
for (const [profile, expectedCap] of DIFFICULTIES.map(
(difficulty, index) => [difficulty, Math.min(index + 1, 3)] as const,
)) {
const game = spiceBankerGame(true);
game.players[0].spice = 4;
const view = viewGame(game, 'a');
view.players.find((player) => player.id === 'a')!.bot = profile;
const plans = botActions(view).filter(
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
);
assert.ok(plans.length, `${profile} omitted the skilled disc`);
for (const action of plans) {
const support = Number(action.support ?? 0);
assert.equal(
Number(action.bankerSpice ?? 0),
Math.min(expectedCap, Math.max(0, 4 - support)),
`${profile} did not reserve Banker spice after force support`,
);
}
assert.doesNotThrow(
() => applyAction(game, 'a', plans[0]),
`${profile} emitted an illegal Spice Banker plan`,
);
}
});
void test('bots do not emit a currently gated Banker commitment in a combined-module view', () => {
const game = spiceBankerGame();
const view = viewGame(game, 'a');
view.expansions = ['ix'];
for (const difficulty of DIFFICULTIES) {
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const skilled = botActions(view).filter(
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
);
assert.ok(skilled.length);
assert.ok(
skilled.every((action) => !action.bankerSpice),
`${difficulty} emitted a gated Banker commitment`,
);
}
});