Allow Advanced Auditor inspection choice
Verify and publish checkpoint container / container (push) Successful in 11m25s
Verify and publish checkpoint container / container (push) Successful in 11m25s
This commit is contained in:
1 parent
0681529e8e
commit
7e6e8c2d12
9 files changed
+292
-31
No files matched your search
@@ -0,0 +1,161 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createAuditorLeader,
|
||||
auditCandidates,
|
||||
auditCount,
|
||||
selectedAuditCount,
|
||||
sampleAuditCards,
|
||||
} from '../game/choam-auditor';
|
||||
import { matchingTraitor, traitorDeck } from '../game/traitors';
|
||||
@@ -216,6 +217,43 @@ void test('partial sampling honors exclusions and valid RNG endpoints without ch
|
||||
assert.deepEqual(original, before);
|
||||
});
|
||||
|
||||
void test('Auditor can select one uniformly from two eligible cards without changing the hand', () => {
|
||||
const original = hand();
|
||||
const before = structuredClone(original);
|
||||
const used = original.slice(2).map((card) => card.id);
|
||||
assert.equal(auditCount(original, used, true), 2);
|
||||
for (const [draw, expected] of [[0, 'shield-a'], [0.5, 'shield-b']] as const) {
|
||||
let calls = 0;
|
||||
const selected = sampleAuditCards(original, used, true, () => { calls++; return draw; }, 1);
|
||||
assert.deepEqual(selected.map((card) => card.id), [expected]);
|
||||
assert.equal(calls, 1);
|
||||
selected[0].name = 'Changed snapshot';
|
||||
}
|
||||
assert.deepEqual(original, before);
|
||||
assert.deepEqual(used, ['hero', 'poison', 'portable']);
|
||||
});
|
||||
|
||||
void test('explicit Auditor inspection sizes respect actual survival and eligible bounds', () => {
|
||||
const original = hand();
|
||||
const before = structuredClone(original);
|
||||
const neverDraw = (): number => { throw new Error('Unexpected randomness'); };
|
||||
assert.equal(selectedAuditCount(0), 0);
|
||||
assert.equal(selectedAuditCount(2), 2);
|
||||
assert.equal(selectedAuditCount(2, 1), 1);
|
||||
assert.equal(selectedAuditCount(2, 2), 2);
|
||||
assert.deepEqual(sampleAuditCards(original.slice(0, 2), [], true, neverDraw, 2), original.slice(0, 2));
|
||||
assert.deepEqual(sampleAuditCards(original.slice(0, 1), [], false, neverDraw, 1), original.slice(0, 1));
|
||||
for (const bad of [0, -1, 1.5, 3, NaN, Infinity, null, '1']) {
|
||||
assert.throws(() => sampleAuditCards(original, [], true, neverDraw, bad as number), /inspection count/);
|
||||
}
|
||||
assert.throws(() => sampleAuditCards(original, [], false, neverDraw, 2), /inspection count/);
|
||||
assert.throws(() => sampleAuditCards(original, original.map((card) => card.id), true, neverDraw, 1), /inspection count/);
|
||||
assert.throws(() => selectedAuditCount(-1), /inspection maximum/);
|
||||
assert.throws(() => selectedAuditCount(3), /inspection maximum/);
|
||||
assert.throws(() => selectedAuditCount(NaN), /inspection maximum/);
|
||||
assert.deepEqual(original, before);
|
||||
});
|
||||
|
||||
void test('invalid physical pools, survival states and RNG draws reject without mutating inputs', () => {
|
||||
const original = hand();
|
||||
const before = structuredClone(original);
|
||||
|
||||
Reference in new issue
Block a user