323 lines
17 KiB
TypeScript
323 lines
17 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
compileMixedActionExpression,
|
|
mixedActionExpressionSize,
|
|
mixedActionExpressionText,
|
|
MixedActionQuestionError,
|
|
parseMixedActionExpression,
|
|
type CompiledMixedAction,
|
|
type MixedActionExpression,
|
|
type MixedActionExpressionLimits,
|
|
} from '../game/mixed-action-question';
|
|
import { parseMovementClaim, type MovementClaim } from '../game/movement-promises';
|
|
import type { TruthAnswer, TruthFact } from '../game/truthtrance';
|
|
|
|
type Expression = MixedActionExpression<MovementClaim>;
|
|
type Compiled = CompiledMixedAction<MovementClaim>;
|
|
const fact = (value = 5): Expression => ({
|
|
kind: 'fact', fact: { kind: 'spice', compare: 'gte', value },
|
|
});
|
|
const action = (territory = 'carthag', minimum = 3): Expression => ({
|
|
kind: 'action', action: { territory, minimum },
|
|
});
|
|
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
|
|
|
// Fixtures isolate the shared budget; the caller retains the authoritative fact decoder.
|
|
const decodeFact = (input: unknown): TruthFact => structuredClone(input) as TruthFact;
|
|
const decodeAction = (input: unknown): MovementClaim => {
|
|
assert(input && typeof input === 'object' && !Array.isArray(input));
|
|
assert.equal(Object.keys(input).sort().join(','), 'minimum,territory');
|
|
return parseMovementClaim(input);
|
|
};
|
|
const decode = (input: unknown, limits?: MixedActionExpressionLimits): Expression =>
|
|
parseMixedActionExpression(input, decodeFact, decodeAction, limits);
|
|
const compile = (input: Expression, factAnswer: TruthAnswer, answer = true): Compiled =>
|
|
compileMixedActionExpression(input, () => factAnswer, answer);
|
|
const compiledAction = (territory = 'carthag', minimum = 3): Compiled => ({
|
|
kind: 'action', action: { territory, minimum },
|
|
});
|
|
const constant = (value: boolean): Compiled => ({ kind: 'constant', value });
|
|
|
|
function evaluate(compiled: Compiled, values: Readonly<Record<string, boolean>>): boolean {
|
|
if (compiled.kind === 'constant') return compiled.value;
|
|
if (compiled.kind === 'action') return values[compiled.action.territory] ?? false;
|
|
const terms = compiled.terms.map(term => evaluate(term, values));
|
|
return compiled.kind === 'and' ? terms.every(Boolean) : terms.some(Boolean);
|
|
}
|
|
|
|
function assertPureResidual(compiled: Compiled): void {
|
|
if (compiled.kind !== 'and' && compiled.kind !== 'or') return;
|
|
assert(compiled.terms.length >= 2);
|
|
for (const term of compiled.terms) {
|
|
assert.notEqual(term.kind, 'constant');
|
|
assertPureResidual(term);
|
|
}
|
|
}
|
|
|
|
void test('mixed action parsing requires both families and preserves raw nested grouping', () => {
|
|
const original = group('and', fact(), group('or', action(), action('arrakeen', 2)));
|
|
const parsed = decode(original);
|
|
assert.deepEqual(parsed, original);
|
|
assert.notEqual(parsed, original);
|
|
if ('terms' in parsed && 'terms' in original) {
|
|
assert.notEqual(parsed.terms, original.terms);
|
|
assert.notEqual(parsed.terms[0], original.terms[0]);
|
|
}
|
|
for (const input of [fact(), action(), group('and', fact(), fact(10)),
|
|
group('or', action(), action('arrakeen'))])
|
|
assert.throws(() => decode(input), MixedActionQuestionError);
|
|
});
|
|
|
|
void test('optional current facts permit all-action travel questions without weakening other defaults or budgets', () => {
|
|
for (const original of [action(), group('or', action(), action('arrakeen')),
|
|
group('and', fact(), action())])
|
|
assert.deepEqual(decode(original, { requireFact: false }), original);
|
|
for (const original of [fact(), group('and', fact(), fact(10))])
|
|
assert.throws(() => decode(original, { requireFact: false }), MixedActionQuestionError);
|
|
assert.throws(() => decode(action(), { requireFact: true }), MixedActionQuestionError);
|
|
assert.throws(() => decode(group('and', action(), action()), { requireFact: false, maxLeaves: 1 }),
|
|
MixedActionQuestionError);
|
|
assert.throws(() => decode(group('and', action(), action()), { requireFact: false, maxDepth: 0 }),
|
|
MixedActionQuestionError);
|
|
for (const requireFact of [null, 0, 'false'])
|
|
assert.throws(() => decode(action(), { requireFact } as unknown as MixedActionExpressionLimits), MixedActionQuestionError);
|
|
});
|
|
|
|
void test('mixed action public parsing rejects frozen constants, metadata and malformed groups everywhere', () => {
|
|
const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] },
|
|
{ kind: 'or', terms: [fact()] }, { kind: 'xor', terms: [fact(), action()] },
|
|
{ kind: 'and', terms: [fact(), action()], answer: true },
|
|
{ ...fact(), value: true }, { ...action(), target: 'player' },
|
|
{ kind: 'constant', value: true }, { constant: 'no' },
|
|
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 }, constant: 'yes' },
|
|
{ kind: 'action', action: { territory: 'carthag', minimum: 3 }, turn: 1 }];
|
|
for (const input of invalid) {
|
|
assert.throws(() => decode(input), MixedActionQuestionError);
|
|
assert.throws(() => decode(group('or', fact(), action(), input as Expression)));
|
|
}
|
|
for (const input of [{ territory: 'carthag', minimum: 3, target: 'player' },
|
|
{ territory: 'carthag', minimum: 3, turn: 1 },
|
|
{ kind: 'constant', value: true },
|
|
{ kind: 'and', terms: [{ territory: 'carthag', minimum: 3 }] }])
|
|
assert.throws(() => decode(group('and', fact(), { kind: 'action', action: input } as Expression)));
|
|
});
|
|
|
|
void test('decisive current-fact siblings cannot hide invalid action or fact subtrees', () => {
|
|
const forbidden = new Error('This fact is unavailable.');
|
|
for (const kind of ['and', 'or'] as const) {
|
|
for (const reverse of [false, true]) {
|
|
const invalid = action('missing', 3);
|
|
const terms = reverse ? [invalid, fact()] : [fact(), invalid];
|
|
assert.throws(() => decode(group(kind, ...terms)));
|
|
const input = group(kind, fact(), action(), fact(10));
|
|
assert.throws(() => parseMixedActionExpression(input, value => {
|
|
const decoded = decodeFact(value);
|
|
if (decoded.kind === 'spice' && decoded.value === 10) throw forbidden;
|
|
return decoded;
|
|
}, decodeAction), error => error === forbidden);
|
|
}
|
|
}
|
|
const badAction = new Error('This action is unavailable.');
|
|
assert.throws(() => parseMixedActionExpression(group('or', fact(), action()), decodeFact,
|
|
() => { throw badAction; }), error => error === badAction);
|
|
});
|
|
|
|
void test('one sixteen-leaf budget includes every nested TruthFact leaf rather than wrappers or groups', () => {
|
|
assert.doesNotThrow(() => decode(group('and',
|
|
...Array.from({ length: 15 }, () => fact()), action())));
|
|
assert.throws(() => decode(group('and',
|
|
...Array.from({ length: 16 }, () => fact()), action())), MixedActionQuestionError);
|
|
const embedded: Expression = { kind: 'fact', fact: {
|
|
kind: 'or', terms: [
|
|
{ kind: 'spice', compare: 'gte', value: 5 },
|
|
{ kind: 'and', terms: [
|
|
{ kind: 'spice', compare: 'gte', value: 10 },
|
|
{ kind: 'spice', compare: 'gte', value: 15 },
|
|
] },
|
|
],
|
|
} };
|
|
assert.doesNotThrow(() => decode(group('or',
|
|
...Array.from({ length: 5 }, () => embedded), action())));
|
|
assert.throws(() => decode(group('or',
|
|
...Array.from({ length: 5 }, () => embedded), fact(), action())), MixedActionQuestionError);
|
|
});
|
|
|
|
void test('one four-level budget includes nested current-fact AND/OR and supports explicit smaller limits', () => {
|
|
const embedded: Expression = { kind: 'fact', fact: { kind: 'and', terms: [
|
|
{ kind: 'spice', compare: 'gte', value: 5 },
|
|
{ kind: 'spice', compare: 'gte', value: 10 },
|
|
] } };
|
|
let nested: Expression = embedded;
|
|
for (let depth = 0; depth < 3; depth++) nested = group('or', action(), nested);
|
|
assert.doesNotThrow(() => decode(nested));
|
|
assert.throws(() => decode(group('and', action(), nested)), MixedActionQuestionError);
|
|
const simple = group('and', fact(), action());
|
|
assert.deepEqual(decode(simple, { maxDepth: 1, maxLeaves: 2 }), simple);
|
|
assert.throws(() => decode(simple, { maxDepth: 0 }), MixedActionQuestionError);
|
|
assert.throws(() => decode(simple, { maxLeaves: 1 }), MixedActionQuestionError);
|
|
assert.throws(() => decode(group('and', embedded, action()), { maxDepth: 1 }), MixedActionQuestionError);
|
|
for (const limits of [{ maxDepth: -1 }, { maxDepth: 1.5 }, { maxLeaves: 0 },
|
|
{ maxLeaves: Infinity }, { maxLeaves: Number.MAX_SAFE_INTEGER + 1 }])
|
|
assert.throws(() => decode(simple, limits), MixedActionQuestionError);
|
|
});
|
|
|
|
void test('shared logical size counts actual fact groups once and measures every group arity', () => {
|
|
const embedded: Expression = { kind: 'fact', fact: { kind: 'or', terms: [
|
|
{ kind: 'spice', compare: 'gte', value: 5 },
|
|
{ kind: 'and', terms: [
|
|
{ kind: 'spice', compare: 'gte', value: 10 },
|
|
{ kind: 'spice', compare: 'gte', value: 15 },
|
|
{ kind: 'spice', compare: 'gte', value: 20 },
|
|
] },
|
|
] } };
|
|
assert.deepEqual(mixedActionExpressionSize(fact()), { leaves: 1, depth: 0, nodes: 1, maxTerms: 0 });
|
|
assert.deepEqual(mixedActionExpressionSize(action()), { leaves: 1, depth: 0, nodes: 1, maxTerms: 0 });
|
|
assert.deepEqual(mixedActionExpressionSize(embedded), { leaves: 4, depth: 2, nodes: 6, maxTerms: 3 });
|
|
assert.deepEqual(mixedActionExpressionSize(group('and', embedded, action())),
|
|
{ leaves: 5, depth: 3, nodes: 8, maxTerms: 3 });
|
|
});
|
|
|
|
void test('optional node limits share one full budget across action leaves and embedded fact groups', () => {
|
|
const embedded = (length: number): Expression => ({ kind: 'fact', fact: {
|
|
kind: 'or', terms: Array.from({ length }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
|
|
} });
|
|
const atLimit = group('and', embedded(7), ...Array.from({ length: 7 }, () => action()));
|
|
assert.deepEqual(mixedActionExpressionSize(atLimit), { leaves: 14, depth: 2, nodes: 16, maxTerms: 8 });
|
|
assert.deepEqual(decode(atLimit, { maxNodes: 16 }), atLimit);
|
|
const overLimit = group('and', ...Array.from({ length: 7 }, () => action()), embedded(8));
|
|
assert.doesNotThrow(() => decode(overLimit));
|
|
assert.throws(() => decode(overLimit, { maxNodes: 16 }), MixedActionQuestionError);
|
|
assert.throws(() => decode(group('and', fact(), action()), { maxNodes: 2 }), MixedActionQuestionError);
|
|
for (const maxNodes of [0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1])
|
|
assert.throws(() => decode(atLimit, { maxNodes }), MixedActionQuestionError);
|
|
});
|
|
|
|
void test('optional group limits include late fact subgroups while absent limits preserve wider legacy groups', () => {
|
|
const wideOuter = group('or', fact(), ...Array.from({ length: 8 }, () => action()));
|
|
const wideFact: Expression = { kind: 'fact', fact: { kind: 'and', terms:
|
|
Array.from({ length: 9 }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
|
|
} };
|
|
const wideEmbedded = group('or', fact(), action(), wideFact);
|
|
for (const original of [wideOuter, wideEmbedded]) {
|
|
assert.deepEqual(decode(original), original);
|
|
assert.throws(() => decode(original, { maxTerms: 8 }), MixedActionQuestionError);
|
|
}
|
|
const eight = group('and', fact(), ...Array.from({ length: 7 }, () => action()));
|
|
assert.deepEqual(decode(eight, { maxTerms: 8 }), eight);
|
|
for (const maxTerms of [0, 1, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1])
|
|
assert.throws(() => decode(eight, { maxTerms }), MixedActionQuestionError);
|
|
});
|
|
|
|
void test('known facts fold whole AND/OR questions independently of the selected Yes or No answer', () => {
|
|
for (const answer of [false, true]) {
|
|
assert.deepEqual(compile(group('and', fact(), action()), 'yes', answer), compiledAction());
|
|
assert.deepEqual(compile(group('and', fact(), action()), 'no', answer), constant(false));
|
|
assert.deepEqual(compile(group('or', fact(), action()), 'yes', answer), constant(true));
|
|
assert.deepEqual(compile(group('or', fact(), action()), 'no', answer), compiledAction());
|
|
}
|
|
});
|
|
|
|
void test('unknown facts conservatively constrain whole Yes and whole No for AND and OR', () => {
|
|
for (const answer of [false, true]) {
|
|
const and = compile(group('and', fact(), action()), 'unknown', answer);
|
|
const or = compile(group('or', fact(), action()), 'unknown', answer);
|
|
assert.deepEqual(and, answer ? constant(false) : compiledAction());
|
|
assert.deepEqual(or, answer ? compiledAction() : constant(true));
|
|
for (const actualFact of [false, true]) for (const actualAction of [false, true]) {
|
|
// Any accepted residual guarantees the whole selected answer for either unknown outcome.
|
|
if (evaluate(and, { carthag: actualAction }) === answer)
|
|
assert.equal(actualFact && actualAction, answer);
|
|
if (evaluate(or, { carthag: actualAction }) === answer)
|
|
assert.equal(actualFact || actualAction, answer);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('nested unknown clauses keep every whole-No alternative without negating individual actions', () => {
|
|
const asked = group('and', action(), group('or', fact(), action('arrakeen')));
|
|
const compiled = compile(asked, 'unknown', false);
|
|
assert.deepEqual(compiled, compiledAction());
|
|
assert.equal(evaluate(compiled, { carthag: false, arrakeen: true }), false);
|
|
assert.equal(evaluate(compiled, { carthag: true, arrakeen: false }), true);
|
|
const alternate = compile(group('or', fact(), group('and', action(), action('arrakeen'))), 'no', false);
|
|
assert.deepEqual(alternate, { kind: 'and', terms: [compiledAction(), compiledAction('arrakeen')] });
|
|
assert.equal(evaluate(alternate, { carthag: true, arrakeen: false }), false);
|
|
assert.equal(evaluate(alternate, { carthag: false, arrakeen: true }), false);
|
|
assert.equal(evaluate(alternate, { carthag: true, arrakeen: true }), true);
|
|
});
|
|
|
|
void test('all facts are answered once before decisive folding, including later nested branches', () => {
|
|
const asked = group('or', fact(1), group('and', fact(2), action()), fact(3));
|
|
const seen: number[] = [];
|
|
assert.deepEqual(compileMixedActionExpression(asked, current => {
|
|
assert.equal(current.kind, 'spice');
|
|
if (current.kind !== 'spice') throw new Error('Unexpected fact.');
|
|
seen.push(current.value);
|
|
return current.value === 1 ? 'yes' : 'no';
|
|
}, true), constant(true));
|
|
assert.deepEqual(seen, [1, 2, 3]);
|
|
const invalidLater = new Error('Answer-time fact failure.');
|
|
assert.throws(() => compileMixedActionExpression(asked, current => {
|
|
if (current.kind === 'spice' && current.value === 3) throw invalidLater;
|
|
return 'yes';
|
|
}, true), error => error === invalidLater);
|
|
});
|
|
|
|
void test('snapshot compilation survives later current-fact changes and JSON without retaining callbacks', () => {
|
|
let spice = 7;
|
|
let calls = 0;
|
|
const answerFact = (current: TruthFact): TruthAnswer => {
|
|
calls++;
|
|
assert.equal(current.kind, 'spice');
|
|
return current.kind === 'spice' && spice >= current.value ? 'yes' : 'no';
|
|
};
|
|
const asked = decode(group('or', fact(), action()));
|
|
const frozen = compileMixedActionExpression(asked, answerFact, true);
|
|
assert.equal(calls, 1);
|
|
spice = 0;
|
|
assert.deepEqual(frozen, constant(true));
|
|
assert.deepEqual(JSON.parse(JSON.stringify(frozen)) as Compiled, constant(true));
|
|
assert.equal(calls, 1);
|
|
assert.deepEqual(compileMixedActionExpression(asked, answerFact, true), compiledAction());
|
|
assert.equal(calls, 2);
|
|
const negative = compileMixedActionExpression(group('and', fact(), action()), answerFact, false);
|
|
spice = 20;
|
|
assert.deepEqual(negative, constant(false));
|
|
});
|
|
|
|
void test('folding removes all internal constants and singleton groups while retaining multi-action choices', () => {
|
|
const asked = group('and', fact(1), group('or', fact(2), action(), action('arrakeen')),
|
|
group('and', fact(3), action('polar_sink')));
|
|
const compiled = compileMixedActionExpression(asked, current =>
|
|
current.kind === 'spice' && current.value === 2 ? 'no' : 'yes', true);
|
|
assert.deepEqual(compiled, { kind: 'and', terms: [
|
|
{ kind: 'or', terms: [compiledAction(), compiledAction('arrakeen')] },
|
|
compiledAction('polar_sink'),
|
|
] });
|
|
assertPureResidual(compiled);
|
|
const entirelyKnown = group('or', group('and', fact(), fact()), group('and', fact(), action()));
|
|
assert.deepEqual(compile(entirelyKnown, 'no'), constant(false));
|
|
assert.deepEqual(compile(entirelyKnown, 'yes'), constant(true));
|
|
});
|
|
|
|
void test('public text retains every original clause after private folding and trees stay immutable', () => {
|
|
const original = group('or', fact(5), group('and', action(), action('arrakeen', 2)));
|
|
const snapshot = structuredClone(original);
|
|
const freeze = (node: Expression): void => {
|
|
if (node.kind === 'fact') Object.freeze(node.fact);
|
|
else if (node.kind === 'action') Object.freeze(node.action);
|
|
else { node.terms.forEach(freeze); Object.freeze(node.terms); }
|
|
Object.freeze(node);
|
|
};
|
|
freeze(original);
|
|
assert.deepEqual(decode(original), snapshot);
|
|
assert.deepEqual(compile(original, 'yes'), constant(true));
|
|
const text = mixedActionExpressionText(original,
|
|
current => current.kind === 'spice' ? `have at least ${current.value} spice` : 'current fact',
|
|
move => `move at least ${move.minimum} to ${move.territory}`);
|
|
assert.equal(text, '(have at least 5 spice OR (move at least 3 to carthag AND move at least 2 to arrakeen))');
|
|
assert.deepEqual(original, snapshot);
|
|
assert.equal(text.includes('constant'), false);
|
|
});
|