From 7e6e8c2d120cd74bf6cb74b1475783de54a7d65f Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 29 Oct 2026 05:52:58 +1100
Subject: [PATCH] Allow Advanced Auditor inspection choice
---
components/choam-auditor.tsx | 48 +++++----
docs/CHOAM_AUDITOR.md | 12 +++
docs/CURRENT_STATUS.md | 9 ++
docs/RULE_DECISIONS.md | 8 ++
game/choam-auditor.ts | 16 ++-
game/engine.ts | 25 +++--
game/reference.ts | 6 +-
tests/choam-auditor-choice.test.ts | 161 +++++++++++++++++++++++++++++
tests/choam-auditor.test.ts | 38 +++++++
9 files changed, 292 insertions(+), 31 deletions(-)
create mode 100644 tests/choam-auditor-choice.test.ts
diff --git a/components/choam-auditor.tsx b/components/choam-auditor.tsx
index 48a97f05..fbe6613e 100644
--- a/components/choam-auditor.tsx
+++ b/components/choam-auditor.tsx
@@ -25,7 +25,7 @@ export function AuditorDecision({
{payment
? `Pay ${audit.count} spice to CHOAM to cancel the entire inspection, or allow it without payment.`
- : `Your Auditor ${audit.survived ? 'survived' : 'died'}. You may inspect ${audit.count} random card${audit.count === 1 ? '' : 's'} from ${opponent?.name ?? 'your opponent'}’s hand. Cards used in this battle are excluded.`}
+ : `Your Auditor ${audit.survived ? 'survived' : 'died'}. You may inspect up to ${audit.count} random card${audit.count === 1 ? '' : 's'} from ${opponent?.name ?? 'your opponent'}’s hand. Cards used in this battle are excluded.`}
{!payment && (
@@ -34,25 +34,33 @@ export function AuditorDecision({
)}
-
- act(
- payment
- ? {
- type: 'decision',
- event: audit.event,
- pay: true,
- count: audit.count,
- }
- : { type: 'decision', event: audit.event, audit: true },
- )
- }
- >
- {payment
- ? `Pay ${audit.count} spice · cancel audit`
- : 'Audit opposing hand'}
-
+ {payment ? (
+
+ act({
+ type: 'decision',
+ event: audit.event,
+ pay: true,
+ count: audit.count,
+ })
+ }
+ >
+ {`Pay ${audit.count} spice · cancel audit`}
+
+ ) : (
+ [1, 2].filter((count) => count <= audit.count).map((count) => (
+
+ act({ type: 'decision', event: audit.event, audit: true, count })
+ }
+ >
+ {`Inspect ${count} card${count === 1 ? '' : 's'} · ${count} spice to cancel`}
+
+ ))
+ )}
2)
+ throw new Error('Auditor requires a valid inspection maximum.');
+ if (selectedCount === undefined) return maximum;
+ if (!Number.isInteger(selectedCount) || selectedCount < 1 || selectedCount > maximum)
+ throw new Error('Auditor inspection count must be between one and the eligible maximum.');
+ return selectedCount;
+}
+
/**
* Call once after the engine resolves cancellation/payment, and persist the
* resulting private snapshot. A read or projection must never call this helper.
* Partial Fisher-Yates samples physical cards uniformly without replacement.
* Selecting the entire eligible pool consumes no randomness.
+ * Omit selectedCount for the legacy maximum; a supplied count must be at
+ * least one and no greater than the current eligible inspection maximum.
*/
export function sampleAuditCards(
hand: readonly Card[],
usedIds: readonly string[],
survived: boolean,
random: () => number,
+ selectedCount?: number,
): Card[] {
const limit = inspectionLimit(survived);
const eligible = auditCandidates(hand, usedIds);
if (typeof random !== 'function')
throw new Error('Auditor requires a server-supplied random source.');
- const count = Math.min(limit, eligible.length);
+ const count = selectedAuditCount(Math.min(limit, eligible.length), selectedCount);
if (count === eligible.length) return eligible;
for (let i = 0; i < count; i++) {
const draw = random();
diff --git a/game/engine.ts b/game/engine.ts
index 1356c74e..08fd1724 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -2346,6 +2346,8 @@ export type Game = {
survived: boolean;
usedCards: string[];
stage: 'offer' | 'response' | 'payment';
+ /** Chosen maximum; older in-flight audits omit this and inspect the maximum. */
+ count?: number;
} | null;
auditorInsight?: {
event: string;
@@ -35521,10 +35523,9 @@ function finishBattle(g: Game) {
function currentAuditCount(g: Game) {
const pending = g.pendingAuditor;
return pending
- ? auditCount(
- getPlayer(g, pending.opponent).hand,
- pending.usedCards,
- pending.survived,
+ ? Math.min(
+ pending.count ?? (pending.survived ? 2 : 1),
+ auditCount(getPlayer(g, pending.opponent).hand, pending.usedCards, pending.survived),
)
: 0;
}
@@ -35554,6 +35555,7 @@ function finishAuditor(
pending.usedCards,
pending.survived,
random,
+ count,
);
g.auditorInsight = {
event: pending.event,
@@ -35617,6 +35619,9 @@ function auditorIntegrity(g: Game) {
g.players.some((p) => p.id === pending.opponent) &&
typeof pending.survived === 'boolean' &&
['offer', 'response', 'payment'].includes(pending.stage) &&
+ (pending.count === undefined ||
+ (pending.stage !== 'offer' && Number.isInteger(pending.count) &&
+ pending.count >= 1 && pending.count <= (pending.survived ? 2 : 1))) &&
Array.isArray(pending.usedCards) &&
pending.usedCards.every((id) => typeof id === 'string') &&
new Set(pending.usedCards).size === pending.usedCards.length,
@@ -43808,11 +43813,17 @@ function applyActionInner(
requireRule(
typeof action.audit === 'boolean' &&
action.pay === undefined &&
- action.count === undefined,
- 'Choose whether to use the Auditor.',
+ (action.audit
+ ? currentAuditCount(g) > 0 &&
+ (action.count === undefined ||
+ (typeof action.count === 'number' && Number.isInteger(action.count) &&
+ action.count >= 1 && action.count <= currentAuditCount(g)))
+ : action.count === undefined),
+ 'Choose a valid number of cards to audit or decline.',
);
if (!action.audit) finishAuditor(g, 'decline');
else {
+ pending.count = typeof action.count === 'number' ? action.count : currentAuditCount(g);
pending.stage = 'response';
g.response = {
kind: 'choamAudit',
@@ -43822,7 +43833,7 @@ function applyActionInner(
};
log(
g,
- `${p.name} declared an Auditor inspection. Opposing Karama cancellation resolves before any inspection payment.`,
+ `${p.name} declared an Auditor inspection of up to ${pending.count} card${pending.count === 1 ? '' : 's'}. Opposing Karama cancellation resolves before any inspection payment.`,
);
}
} else {
diff --git a/game/reference.ts b/game/reference.ts
index 70fd681a..f2d52e6b 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -5010,9 +5010,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'An additional strength-2 leader inspects unused opposing hand cards after battle.',
steps: [
'Advanced CHOAM adds the Auditor and its matching traitor identity before setup draws. The five ordinary CHOAM leaders are unchanged. The Auditor can also appear in the remaining Face Dancer pool.',
- 'Using the Auditor in a battle grants an optional inspection of two random opposing hand cards if it survives, or one if it dies. Victory is not required. Cards that opposing player used in the battle are excluded, including their retained weapon, defense, hero or late Portable Snooper. A later-acquired card used only by the other combatant can remain eligible; the current hand determines the pool.',
- 'Choose Audit or Decline after battle cleanup. The table completes battle income, tech claims and Harkonnen capture before this choice, then resolves Face Dancing afterward. Auditor survival is recorded from the battle itself.',
- 'Karama cancellation resolves first. If the power remains active, the opponent can pay CHOAM one spice per actually viewable card to cancel the entire inspection. A surviving Auditor with only one eligible card costs one spice to stop. There is no partial payment.',
+ 'Using the Auditor in a battle grants an optional inspection of up to two random opposing hand cards if it survives, or one if it dies. Victory is not required. Cards that opposing player used in the battle are excluded, including their retained weapon, defense, hero or late Portable Snooper. A later-acquired card used only by the other combatant can remain eligible; the current hand determines the pool.',
+ 'Choose how many cards to inspect, or decline, after battle cleanup. The table completes battle income, tech claims and Harkonnen capture before this choice, then resolves Face Dancing afterward. Auditor survival is recorded from the battle itself.',
+ 'Karama cancellation resolves first. If the power remains active, the opponent can pay CHOAM one spice per actually viewable card within the chosen count to cancel the entire inspection. Choosing one card costs at most one spice to stop. There is no partial payment.',
'If the opponent cannot afford the full payment, inspection proceeds automatically. An empty eligible hand needs no decision. Sampling happens once after cancellation choices; no card is transferred or discarded.',
'Only CHOAM receives the inspected faces. Its private snapshot survives refresh and the transition out of Battle, and expires at the next battle or turn. It does not track later changes in the opposing hand and needs no confirmation to continue.',
'The Auditor cannot be captured, acquired as a foreign ghola by Tleilaxu, or receive a leader skill. CHOAM’s own Ghola card can revive it. Supplied Advanced p29 permits ordinary revival each turn even after repeated deaths, for two spice before discounts, using the usual one-leader allowance.',
diff --git a/tests/choam-auditor-choice.test.ts b/tests/choam-auditor-choice.test.ts
new file mode 100644
index 00000000..728fd6c9
--- /dev/null
+++ b/tests/choam-auditor-choice.test.ts
@@ -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);
+ }
+});
diff --git a/tests/choam-auditor.test.ts b/tests/choam-auditor.test.ts
index f31f3922..cac65d97 100644
--- a/tests/choam-auditor.test.ts
+++ b/tests/choam-auditor.test.ts
@@ -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);