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 === 'next/link' ? 'vinext/shims/link' : 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"/); const text = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' '); assert.match(text, /Plan funding: 2 own spice for force support \+ 2 committed to Spice Banker = 4 own spice total/); assert.match(text, /Each committed spice adds 1 strength if this skilled leader survives/); assert.match(text, /commitment goes to the Bank after a win or loss/); assert.match(text, /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 combined = structuredClone(native); combined.expansions = ['ix']; assert.match(renderControl(viewGame(combined, 'a'), 'emperor-0', 2, 2), /id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/); 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, noFieldPlayers: [], 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>
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 fund original and combined-deck Banker plans without double-spending force support', () => { for (const combined of [false, true]) for (const profile of DIFFICULTIES) { const game = spiceBankerGame(true); game.expansions = combined ? ['ix'] : []; 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', ); const selected = plans.find(action => Number(action.bankerSpice ?? 0) > 0); assert.ok(selected, `${profile} needs a legal positive Banker commitment`); for (const action of plans) { const support = Number(action.support ?? 0), banker = Number(action.bankerSpice ?? 0); assert.ok(Number.isSafeInteger(banker) && banker >= 0 && banker <= 3); assert.ok(support + banker <= 4, `${profile} exceeds its own available spice`); } let settled = applyAction(game, 'a', selected); assert.equal(settled.players[0].spice, 4, 'Sealing alone does not collect the commitment.'); settled = applyAction(settled, 'd', { type: 'battlePlan', leader: 'guild-1', dial: 0, support: 0 }); settled = applyAction(settled, 'a', { type: 'traitorCall', call: false }); settled = applyAction(settled, 'd', { type: 'traitorCall', call: false }); // Plans without played cards finish cleanup automatically. Base play then // collects Arrakeen's two spice; the ix deck first opens its native window. const cityIncome = settled.phase === 7 && !settled.phaseOpening ? 2 : 0; assert.ok(settled.phase === 6 || settled.phase === 7); assert.equal(settled.players[0].spice, 4 - Number(selected.support ?? 0) - Number(selected.bankerSpice ?? 0) + cityIncome); } });