162 lines
6.7 KiB
TypeScript
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);
|
|
}
|
|
});
|