Files
Dune/tests/choam-auditor-choice.test.ts
T
Quintine 7e6e8c2d12
Verify and publish checkpoint container / container (push) Successful in 11m25s
Allow Advanced Auditor inspection choice
2026-10-29 05:52:58 +11:00

162 lines
6.7 KiB
TypeScript

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);
}
});