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
This commit is contained in:
Quintine committed 2026-09-13 20:45:42 +10:00
1 parent 9381ef140d
commit 1380271bb6
22 files changed
+1166 -25

No files matched your search

+2 -1
View File
@@ -25,7 +25,7 @@ export function advancedAtreidesSukOffer(): Game {
}
/** Genuine module setup, then a conserved, explicitly staged battle position. */
export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, hide = true, advanced = false, elite = false, bluff = false, captured = false, capturedNativeSkill = undefined as LeaderSkillId | undefined, weaponKills = false, dial = 4, atreides = false, territory = 'arrakeen', sector = 10 } = {}): Game {
export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, unsealed = false, hide = true, advanced = false, elite = false, bluff = false, captured = false, capturedNativeSkill = undefined as LeaderSkillId | undefined, weaponKills = false, dial = 4, atreides = false, territory = 'arrakeen', sector = 10 } = {}): Game {
const native = captured ? 'harkonnen' : atreides ? 'atreides' : 'emperor';
let game = dealtSkillGame(native, advanced, skill);
for (const id of ['a', 'd']) {
@@ -89,6 +89,7 @@ export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, hid
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
if (unsealed) return game;
game = applyAction(game, 'a', { type: 'battlePlan', dial, support: advanced ? 3 : 0,
leader: captured ? 'guild-0' : hide && !bluff ? `${native}-0` : `${native}-1`, weapon: played.id });
game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: enemyWeapon });
+218
View File
@@ -0,0 +1,218 @@
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`,
);
}
});
+12
View File
@@ -0,0 +1,12 @@
import { leaderSkillBattle } from './leader-skill-battle-fixture';
/** Genuine skill setup and the shared conserved battle position, before sealing either plan. */
export function spiceBankerGame(advanced = false, captured = false) {
return leaderSkillBattle({
skill: 'spice-banker',
advanced,
captured,
unsealed: true,
dial: 0,
});
}
+282
View File
@@ -0,0 +1,282 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import type { Action } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { spiceBankerGame } from './spice-banker-fixture';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
function giveTraitor(
game: ReturnType<typeof spiceBankerGame>,
owner: string,
identity: string,
) {
const recipient = game.players.find((player) => player.id === owner)!;
if (recipient.traitors.includes(identity)) return;
const replaced = recipient.traitors[0];
for (const player of game.players) {
const index = player.traitors.indexOf(identity);
if (index < 0) continue;
player.traitors[index] = replaced;
recipient.traitors[0] = identity;
return;
}
const index = game.traitorReserve!.indexOf(identity);
assert.ok(index >= 0);
game.traitorReserve![index] = replaced;
recipient.traitors[0] = identity;
}
async function fixture(
options: { advanced?: boolean; traitor?: boolean; spice?: number } = {},
) {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
const created = await store.rooms.createRoom(
'Banker',
'emperor',
options.advanced ?? false,
[],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Guild',
'guild',
);
const code = created.view.code;
const tokens = [created.token!, joined.token!];
const original = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
for (const [index, seat] of original.entries())
sqlite
.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
.run(index === 0 ? 'a' : 'd', code, seat.playerId);
const seats = await Promise.all(
tokens.map((token) => store.restart().authenticate(code, token)),
);
const state = spiceBankerGame(options.advanced ?? false);
state.code = code;
state.version = (await store.rooms.readRoom(code)).version;
state.players[0].spice = options.spice ?? 20;
if (options.traitor) giveTraitor(state, 'a', 'guild-1');
sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(state), state.version, code);
store.writes.length = 0;
return { ...store, sqlite, code, seats, state };
}
const bankerPlan = (amount?: number): Action => ({
type: 'battlePlan',
dial: 0,
support: 0,
leader: 'emperor-0',
...(amount === undefined ? {} : { bankerSpice: amount }),
});
const guildPlan: Action = {
type: 'battlePlan',
dial: 0,
support: 0,
leader: 'guild-1',
};
void test('SQLite restart keeps the Banker commitment owner-private and reserves it from other spending', async () => {
const f = await fixture({ spice: 3 });
try {
await f.rooms.act(
f.code,
f.seats[0],
f.state.version,
bankerPlan(3),
clock,
);
const sealed = await f.restart().readRoom(f.code);
assert.equal(sealed.battle?.plans.a.bankerSpice, 3);
assert.equal(sealed.players[0].spice, 3);
const owner = await f.restart().readSeatView(f.code, f.seats[0]);
const opponent = await f.restart().readSeatView(f.code, f.seats[1]);
assert.equal(owner.battle?.plans.a.bankerSpice, 3);
assert.deepEqual(owner.battle?.submitted, ['a']);
assert.deepEqual(opponent.battle?.plans, {});
assert.deepEqual(opponent.battle?.submitted, ['a']);
assert.equal('bankerSpice' in (opponent.battle?.plans.a ?? {}), false);
await assert.rejects(() =>
f
.restart()
.act(
f.code,
f.seats[0],
sealed.version,
{ type: 'bribe', target: 'd', amount: 1 },
clock,
),
);
assert.deepEqual(await f.restart().readRoom(f.code), sealed);
await f.restart().act(f.code, f.seats[1], sealed.version, guildPlan, clock);
let revealed = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[0],
revealed.version,
{ type: 'traitorCall', call: false },
clock,
);
revealed = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[1],
revealed.version,
{ type: 'traitorCall', call: false },
clock,
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].spice, 0);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Spice Banker payment',
).length,
1,
);
} finally {
f.sqlite.close();
}
});
void test('concurrent final Traitor decisions waive one restored Banker payment exactly once', async () => {
const f = await fixture({ traitor: true });
try {
const bounty = f.state.players
.flatMap((player) => player.leaders)
.find((leader) => leader.id === 'guild-1')!.strength;
await f.rooms.act(
f.code,
f.seats[0],
f.state.version,
bankerPlan(3),
clock,
);
let state = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.seats[1], state.version, guildPlan, clock);
state = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[0],
state.version,
{ type: 'traitorCall', call: true },
clock,
);
const pending = await f.restart().readRoom(f.code);
assert.equal(pending.battle?.plans.a.bankerSpice, 3);
assert.equal(pending.players[0].spice, 20);
let arrivals = 0;
let release!: () => void;
const barrier = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await barrier;
};
f.writes.length = 0;
const finalCall: Action = { type: 'traitorCall', call: false };
const attempts = await Promise.allSettled([
f.rooms.act(f.code, f.seats[1], pending.version, finalCall, clock),
f.restart().act(f.code, f.seats[1], pending.version, finalCall, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(
attempts.filter((attempt) => attempt.status === 'fulfilled').length,
1,
);
assert.equal(
attempts.filter((attempt) => attempt.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, pending.version + 1);
assert.equal(done.battle, null);
assert.equal(done.lastBattleContext?.result, 'traitor');
assert.equal(done.lastBattleContext?.winner, 'a');
assert.equal(done.players[0].spice, 20 + bounty);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Spice Banker payment',
).length,
1,
);
assert.match(
done.log.find(
(entry) => entry.automatic?.name === 'Spice Banker payment',
)!.text,
/waived/,
);
const writes = f.writes.length;
await assert.rejects(() =>
f.restart().act(f.code, f.seats[1], pending.version, finalCall, clock),
);
assert.equal(f.writes.length, writes);
assert.deepEqual(await f.restart().readRoom(f.code), done);
} finally {
f.sqlite.close();
}
});
void test('a restored legacy plan without bankerSpice resolves without reserving or paying it', async () => {
const f = await fixture({ spice: 4 });
try {
await f.rooms.act(f.code, f.seats[0], f.state.version, bankerPlan(), clock);
const sealed = await f.restart().readRoom(f.code);
assert.equal('bankerSpice' in sealed.battle!.plans.a, false);
await f.restart().act(f.code, f.seats[1], sealed.version, guildPlan, clock);
let state = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[0],
state.version,
{ type: 'traitorCall', call: false },
clock,
);
state = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[1],
state.version,
{ type: 'traitorCall', call: false },
clock,
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].spice, 4);
assert.ok(
!done.log.some(
(entry) => entry.automatic?.name === 'Spice Banker payment',
),
);
} finally {
f.sqlite.close();
}
});
+197
View File
@@ -0,0 +1,197 @@
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);
}
}
});