import assert from 'node:assert/strict'; import test from 'node:test'; import { compileMixedActionExpression, mixedActionExpressionSize, MixedActionQuestionError, parseMixedActionExpression, type CompiledMixedAction, type MixedActionExpression, } from '../game/mixed-action-question'; import { MIXED_TRAITOR_MAX_DEPTH, MIXED_TRAITOR_MAX_LEAVES, mixedTraitorRequirement, parseTraitorActionClaim, type MixedTraitorClaim, type TraitorActionClaim, } from '../game/mixed-traitor-question'; import { bindTraitorDecisionPromise, TraitorDecisionError, traitorDecisionChoices, type TraitorDecisionContext, } from '../game/traitor-decision-promises'; import type { TruthAnswer, TruthFact } from '../game/truthtrance'; type Expression = MixedActionExpression; type Compiled = CompiledMixedAction; const action = (call: boolean): Expression & Compiled => ({ kind: 'action', action: { call } }); const fact = (value = 5): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value } }); const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms }); const compiledGroup = (kind: 'and' | 'or', ...terms: Compiled[]): Compiled => ({ kind, terms }); const constant = (value: boolean): Compiled => ({ kind: 'constant', value }); const parse = (input: unknown): Expression => parseMixedActionExpression(input, value => structuredClone(value) as TruthFact, parseTraitorActionClaim); const context = (canReveal: boolean): TraitorDecisionContext => ({ turn: 4, battle: 'battle-event-7', player: 'harkonnen', beneficiary: 'atreides', target: 'emperor', leader: 'aramsham', canReveal, }); void test('strict Traitor leaves copy only the original native Reveal or Decline and metadata stays on the question', () => { for (const call of [false, true]) { const original = { call }; const parsed = parseTraitorActionClaim(original); assert.deepEqual(parsed, original); assert.notEqual(parsed, original); } const claim: MixedTraitorClaim = { battle: 'battle-event-7', territory: 'arrakeen', mixed: group('and', fact(), action(true)), }; assert.deepEqual(parse(claim.mixed), claim.mixed); assert.deepEqual(Object.keys(parseTraitorActionClaim({ call: false })), ['call']); }); void test('strict Traitor leaves reject source, frozen values, wrappers, groups and malformed booleans even after decisive facts', () => { const invalid: unknown[] = [null, [], {}, true, { call: undefined }, { call: null }, { call: 0 }, { call: 'false' }, { call: Object(false) }, { call: true, battle: 'battle-event-7' }, { call: false, territory: 'arrakeen' }, { call: true, target: 'emperor' }, { call: true, turn: 4 }, { call: true, leader: 'aramsham' }, { call: true, event: 'private-link' }, { call: false, answer: true }, { call: true, fulfilled: true }, { call: true, released: false }, { call: true, canReveal: true }, { kind: 'constant', value: true }, { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } }, { kind: 'action', action: { call: true } }, { kind: 'and', terms: [{ call: true }, { call: false }] }, { kind: 'or', terms: [] }]; for (const value of invalid) { assert.throws(() => parseTraitorActionClaim(value), TraitorDecisionError); for (const kind of ['and', 'or'] as const) assert.throws(() => parse(group(kind, fact(), { kind: 'action', action: value } as Expression)), TraitorDecisionError); } }); void test('both whole answers retain exact truth tables through every nested AND or OR combination and constants', () => { const cases: { node: Compiled; decline: boolean; reveal: boolean }[] = [ { node: constant(false), decline: false, reveal: false }, { node: constant(true), decline: true, reveal: true }, { node: action(false), decline: true, reveal: false }, { node: action(true), decline: false, reveal: true }, ]; for (const outer of ['and', 'or'] as const) for (const inner of ['and', 'or'] as const) { for (const first of [false, true]) for (const second of [false, true]) for (const third of [false, true]) { const evaluate = (choice: boolean) => { const left = inner === 'and' ? choice === first && choice === second : choice === first || choice === second; return outer === 'and' ? left && choice === third : left || choice === third; }; cases.push({ node: compiledGroup(outer, compiledGroup(inner, action(first), action(second)), action(third)), decline: evaluate(false), reveal: evaluate(true) }); } } for (const { node, decline, reveal } of cases) for (const answer of [false, true]) { const expected = decline !== answer && reveal !== answer ? { possible: false } : { possible: true, call: decline === answer && reveal === answer ? null : reveal === answer }; assert.deepEqual(mixedTraitorRequirement(node, answer), expected, JSON.stringify({ node, answer })); } assert.throws(() => mixedTraitorRequirement(action(true), 'yes' as unknown as boolean), TraitorDecisionError); }); void test('opposite-call correlations use both logical choices while a forced Decline remains binding after native eligibility changes', () => { const tautology = compiledGroup('or', action(true), action(false)); const contradiction = compiledGroup('and', action(true), action(false)); assert.deepEqual(mixedTraitorRequirement(tautology, true), { possible: true, call: null }); assert.deepEqual(mixedTraitorRequirement(tautology, false), { possible: false }); assert.deepEqual(mixedTraitorRequirement(contradiction, false), { possible: true, call: null }); assert.deepEqual(mixedTraitorRequirement(contradiction, true), { possible: false }); assert.deepEqual(traitorDecisionChoices(context(false), []), [false]); const required = mixedTraitorRequirement(compiledGroup('and', constant(true), action(false)), true); assert.deepEqual(required, { possible: true, call: false }); assert(required.possible && required.call !== null); const promises = bindTraitorDecisionPromise(context(false), [], 'beneGesserit', required.call); assert.deepEqual(traitorDecisionChoices(context(false), promises), [false]); assert.deepEqual(traitorDecisionChoices(context(true), promises), [false], 'A later matching card cannot erase the original Decline'); assert.deepEqual(mixedTraitorRequirement(action(true), true), { possible: true, call: true }, 'Current native eligibility remains the caller’s separate intersection'); }); void test('compilation snapshots every current fact before exact native reduction without mutating the public or compiled trees', () => { const mixed = group('and', group('or', fact(5), action(false)), group('or', fact(9), action(true))); const before = structuredClone(mixed), reads: number[] = []; const residual = compileMixedActionExpression(mixed, current => { assert(current.kind === 'spice'); reads.push(current.value); return current.value === 5 ? 'yes' : 'no'; }, true); assert.deepEqual(reads, [5, 9]); assert.deepEqual(residual, action(true)); const frozen = structuredClone(residual); assert.deepEqual(mixedTraitorRequirement(residual, true), { possible: true, call: true }); assert.deepEqual(mixedTraitorRequirement(residual, false), { possible: true, call: false }); assert.deepEqual(mixed, before); assert.deepEqual(residual, frozen); const decisiveReads: number[] = []; const decisive = compileMixedActionExpression(group('or', fact(5), group('and', fact(9), action(false))), current => { assert(current.kind === 'spice'); decisiveReads.push(current.value); return 'yes'; }, true); assert.deepEqual(decisiveReads, [5, 9]); assert.deepEqual(mixedTraitorRequirement(decisive, true), { possible: true, call: null }); }); void test('unknown facts keep the conservative false-for-Yes or true-for-No snapshot without guessing their correlations', () => { for (const answer of [false, true]) for (const call of [false, true]) for (const kind of ['and', 'or'] as const) { const raw = group(kind, fact(), action(call)); const before = structuredClone(raw); const snapshots: TruthAnswer[] = []; const compiled = compileMixedActionExpression(raw, () => { snapshots.push('unknown'); return 'unknown'; }, answer); const permitsAction = answer ? kind === 'or' : kind === 'and'; assert.deepEqual(mixedTraitorRequirement(compiled, answer), permitsAction ? { possible: true, call: answer ? call : !call } : { possible: false }); assert.deepEqual(snapshots, ['unknown']); assert.deepEqual(raw, before); } }); void test('mixed Traitor questions retain the generic sixteen-leaf and depth-four budgets including embedded fact groups', () => { assert.equal(MIXED_TRAITOR_MAX_LEAVES, 16); assert.equal(MIXED_TRAITOR_MAX_DEPTH, 4); const atLeaves = group('and', fact(), ...Array.from({ length: 15 }, () => action(true))); assert.deepEqual(parse(atLeaves), atLeaves); assert.equal(mixedActionExpressionSize(atLeaves).leaves, 16); assert.throws(() => parse(group('and', atLeaves, action(false))), MixedActionQuestionError); const embedded: Expression = { kind: 'fact', fact: { kind: 'or', terms: Array.from({ length: 15 }, () => ({ kind: 'spice', compare: 'gte', value: 5 })), } }; assert.deepEqual(parse(group('or', embedded, action(false))), group('or', embedded, action(false))); assert.throws(() => parse(group('and', embedded, action(false), action(true))), MixedActionQuestionError); let atDepth = group('and', fact(), action(true)); for (let depth = 1; depth < 4; depth++) atDepth = group('or', action(false), atDepth); assert.equal(mixedActionExpressionSize(atDepth).depth, 4); assert.deepEqual(parse(atDepth), atDepth); assert.throws(() => parse(group('and', action(true), atDepth)), MixedActionQuestionError); });