Files
Dune/game/choam-auditor.ts
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

104 lines
3.6 KiB
TypeScript

import type { Card } from './cards';
export {
CHOAM_AUDITOR_ID,
isAuditorLeader,
createAuditorLeader,
} from './cards';
function physicalId(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
/**
* GF9 CHOAM & Richese p. 8: exclude cards actually used in this battle,
* including any subsequently retained cards. The engine supplies those IDs
* and the opponent's current hand; this helper does not discover hidden state.
*/
export function auditCandidates(
hand: readonly Card[],
usedIds: readonly string[],
): Card[] {
if (
!Array.isArray(hand) ||
!Array.isArray(usedIds) ||
usedIds.some((id) => !physicalId(id))
)
throw new Error('Auditor requires a hand and physical used-card IDs.');
const used = new Set(usedIds);
const seen = new Set<string>();
const eligible: Card[] = [];
for (const card of hand) {
if (!card || !physicalId(card.id) || seen.has(card.id))
throw new Error('Auditor requires unique physical cards in the hand.');
seen.add(card.id);
if (!used.has(card.id)) eligible.push(structuredClone(card));
}
return eligible;
}
function inspectionLimit(survived: boolean): number {
if (typeof survived !== 'boolean')
throw new Error('Auditor requires the actual leader survival outcome.');
return survived ? 2 : 1;
}
/** Also the price to cancel the entire audit (printed p. 10 FAQ). */
export function auditCount(
hand: readonly Card[],
usedIds: readonly string[],
survived: boolean,
): number {
return Math.min(
inspectionLimit(survived),
auditCandidates(hand, usedIds).length,
);
}
/** Validate the holder's chosen size against the actual eligible maximum.
* An omitted choice preserves the legacy maximum, including an empty audit. */
export function selectedAuditCount(maximum: number, selectedCount?: number): number {
if (!Number.isInteger(maximum) || maximum < 0 || maximum > 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 = selectedAuditCount(Math.min(limit, eligible.length), selectedCount);
if (count === eligible.length) return eligible;
for (let i = 0; i < count; i++) {
const draw = random();
if (
typeof draw !== 'number' ||
!Number.isFinite(draw) ||
draw < 0 ||
draw >= 1
)
throw new Error('Auditor random draws must be finite numbers in [0, 1).');
const j = i + Math.floor(draw * (eligible.length - i));
[eligible[i], eligible[j]] = [eligible[j], eligible[i]];
}
return eligible.slice(0, count);
}