import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game, } from '../game/engine'; import { baseDeck, createAuditorLeader, type Card } from '../game/cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { AuditorDecision } from '../components/choam-auditor'; const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); function passResponses(g: Game) { for (let i = 0; g.response && i < 30; i++) { const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id; g = applyAction(g, id, { type: 'passResponse' }); } assert.equal(g.response, null); return g; } function hold(g: Game, kind: Card['kind']) { const index = g.deck.findIndex((card) => card.kind === kind); assert.ok(index >= 0); const [card] = g.deck.splice(index, 1); player(g, 'e').hand.push(card); return card.id; } /** Earn the opportunity with a real Advanced battle, not a fabricated pending audit. */ function offer() { let g = createGame('AUDITCHOICE', newPlayer('c', 'CHOAM', 'choam'), true); g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('g', 'Guild', 'guild')); Object.assign(g, { status: 'playing', phase: 6, turn: 2, active: 'c', order: ['c', 'e', 'g'], storm: 18, deck: baseDeck(), discard: [], }); player(g, 'c').leaders.push(createAuditorLeader()); for (const p of g.players) { p.hand = []; p.spice = 20; p.traitors = []; p.forces = p.id === 'g' ? {} : { 'arrakeen:10': 3 }; p.reserves = p.id === 'g' ? 20 : 17; } const eligible = [hold(g, 'shield'), hold(g, 'snooper'), hold(g, 'projectile')]; const karamaIndex = g.deck.findIndex((card) => card.effect === 'karama'); assert.ok(karamaIndex >= 0); player(g, 'g').hand.push(g.deck.splice(karamaIndex, 1)[0]); g = applyAction(g, 'c', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' }); for (let i = 0; i < 30; i++) { g = passResponses(g); if (g.battle?.preLeader && !g.battle.preLeader.closed) { const id = ['c', 'e'].find((p) => !g.battle!.preLeader!.ready.includes(p))!; g = applyAction(g, id, { type: 'battlePreparationReady', event: g.battle.event }); } else if (g.battle?.preparation) { g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower' }); } else break; } g = applyAction(g, 'c', { type: 'battlePlan', leader: 'choam-auditor', dial: 0, support: 0 }); g = applyAction(g, 'e', { type: 'battlePlan', leader: 'emperor-0', dial: 0, support: 0 }); g = applyAction(g, 'c', { type: 'traitorCall', call: false }); g = applyAction(g, 'e', { type: 'traitorCall', call: false }); g = passResponses(g); assert.equal(g.decision?.kind, 'choamAudit'); assert.equal(g.pendingAuditor?.survived, true); assert.equal(viewGame(g, 'c').auditor?.count, 2); return { g, eligible }; } function declare(g: Game, count?: number) { return applyAction(g, 'c', { type: 'decision', event: g.pendingAuditor!.event, audit: true, ...(count === undefined ? {} : { count }), }); } void test('a surviving Auditor may choose one card and the opponent pays one spice to cancel', () => { let { g } = offer(); const event = g.pendingAuditor!.event; const before = { c: player(g, 'c').spice, e: player(g, 'e').spice }; g = declare(g, 1); assert.equal(g.response?.kind, 'choamAudit'); g = passResponses(reload(g)); assert.equal(g.decision?.kind, 'choamAuditPayment'); assert.equal(viewGame(g, 'e').auditor?.count, 1); g = applyAction(reload(g), 'e', { type: 'decision', event, pay: true, count: 1 }); assert.equal(player(g, 'e').spice, before.e - 1); assert.equal(player(g, 'c').spice, before.c + 1); assert.equal(g.auditorInsight ?? null, null); assert.equal(g.pendingAuditor, null); }); void test('Auditor controls show each valid inspection size and its cancellation price', () => { const { g } = offer(); const render = (game: Game, id: string) => renderToStaticMarkup(createElement(AuditorDecision, { game: viewGame(game, id), act: () => {}, busy: false, })); const offerHtml = render(g, 'c'); assert.match(offerHtml, /Inspect 1 card · 1 spice to cancel/); assert.match(offerHtml, /Inspect 2 cards · 2 spice to cancel/); const payment = passResponses(declare(g, 1)); assert.match(render(payment, 'e'), /Pay 1 spice · cancel audit/); }); void test('a one-card choice survives the response and privately samples one eligible physical card', () => { const offered = offer(); let g = offered.g; const { eligible } = offered; const event = g.pendingAuditor!.event; const hand = structuredClone(player(g, 'e').hand); g = passResponses(reload(declare(g, 1))); assert.equal(viewGame(g, 'e').auditor?.count, 1); g = applyAction(reload(g), 'e', { type: 'decision', event, pay: false }); const insight = viewGame(g, 'c').auditorInsight; assert.equal(insight?.cards.length, 1); assert.ok(eligible.includes(insight!.cards[0].id)); assert.deepEqual(player(g, 'e').hand, hand); assert.equal(viewGame(g, 'e').auditorInsight, null); assert.equal(viewGame(g, 'g').auditorInsight, null); }); void test('two-card and legacy omitted-count declarations retain the full two-spice price', () => { for (const requested of [2, undefined]) { let { g } = offer(); const event = g.pendingAuditor!.event; const before = { c: player(g, 'c').spice, e: player(g, 'e').spice }; g = passResponses(declare(g, requested)); assert.equal(viewGame(g, 'e').auditor?.count, 2); g = applyAction(g, 'e', { type: 'decision', event, pay: true, count: 2 }); assert.equal(player(g, 'e').spice, before.e - 2); assert.equal(player(g, 'c').spice, before.c + 2); } }); void test('invalid audit counts do not consume the opportunity', () => { const { g } = offer(); for (const count of [0, -1, 1.5, 3, '1', null]) { const before = reload(g); const action = { type: 'decision', event: g.pendingAuditor!.event, audit: true, count } as unknown as Action; assert.throws(() => applyAction(g, 'c', action)); assert.deepEqual(g, before); } }); void test('all four minimal bots can still submit the legacy maximum audit action', () => { const { g } = offer(); for (const difficulty of DIFFICULTIES) { const view = viewGame(g, 'c'); view.players.find((p) => p.id === 'c')!.bot = difficulty; const action = botActions(view)[0]; assert.equal(action.type, 'decision'); assert.equal(action.audit, true); const next = passResponses(applyAction(reload(g), 'c', action)); assert.equal(viewGame(next, 'e').auditor?.count, 2); } });