Combine current facts with native Traitor decisions

This commit is contained in:
Quintine committed 2026-10-26 18:52:06 +11:00
1 parent 812f77e348
commit 13398aa76e
22 files changed
+1512 -17

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
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<TraitorActionClaim>;
type Compiled = CompiledMixedAction<TraitorActionClaim>;
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);
});