Files
Dune/tests/spice-banker-controls.test.tsx
Quintine 178251ad35 Restore native Ecaz simulation continuation and current rule fixtures
Match original Ecaz sample snapshots to their native Occupy and Advanced assassination state. Preserve older absent-power samples and resume the original scenario instead of rejecting fresh saved output. Update stale fixture reactions and module expectations while retaining meaningful Banker guidance and positive legal commitments. Correct the unsupported rules-completion claim.

Validation: typecheck/lint, 173 affected tests across 16 files, bounded independent source review, and one direct Ecaz continuation completed through 178 accepted actions. Runtime fingerprint 95d009d3957650b08bb24826b70aa5f00a74cdd9992b6a6c222f0665d3e3759c; documentation-only proof update a91ab248109bee74a74373fb544027a2e7cc8f178e26d2c9dad475a9618eb64d. Full rules, comprehensive assurance and deployed acceptance remain open. No saved-room or provider changes.
2026-10-18 10:08:55 +11:00

200 lines
6.9 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 === '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><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 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);
}
});