213 lines
15 KiB
TypeScript
213 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
ALLIANCE_EXPRESSION_MAX_DEPTH, ALLIANCE_EXPRESSION_MAX_LEAVES,
|
|
AllianceExpressionPromiseError, allianceExpressionOutcomes, allianceExpressionPromisesMatch,
|
|
allianceExpressionText, evaluateAllianceExpression, findAllianceExpressionPlans,
|
|
liveAllianceExpressionPromises, parseAllianceExpression, parseAllianceExpressionClaim,
|
|
validateAllianceExpressionPromises, type AllianceAction, type AllianceExpression,
|
|
type AllianceExpressionContext, type AllianceExpressionPromise,
|
|
} from '../game/alliance-expression-promises';
|
|
import { findAlliancePromisePlan, type AllianceOpportunity, type AlliancePromise } from '../game/alliance-promises';
|
|
import { quoteNexusAlliance } from '../game/nexus-alliance';
|
|
|
|
const seats = ['respondent', 'partner-a', 'partner-b', 'partner-c', 'asker'];
|
|
const opportunity = (): AllianceOpportunity => ({ turn: 2, source: 'original-worm-nexus' });
|
|
const leaf = (partner: string): AllianceExpression => ({ kind: 'partner', partner });
|
|
const group = (kind: 'and' | 'or', ...terms: AllianceExpression[]): AllianceExpression => ({ kind, terms });
|
|
const either = () => group('or', leaf('partner-a'), leaf('partner-b'));
|
|
function promise(event: string, claim = either(), extra: Partial<AllianceExpressionPromise> = {}): AllianceExpressionPromise {
|
|
return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker', claim, answer: true, ...extra };
|
|
}
|
|
function scalar(event: string, partner: string, answer: boolean): AlliancePromise {
|
|
return { event, turn: 2, source: 'original-worm-nexus', player: 'respondent', asker: 'asker', partner, answer };
|
|
}
|
|
function context(): AllianceExpressionContext {
|
|
return { players: seats.map(id => ({ id, ally: null })), offers: {} };
|
|
}
|
|
function action(state: AllianceExpressionContext, actor: string, target: string | null): AllianceExpressionContext {
|
|
const quoted = quoteNexusAlliance({ ...state, actor, target });
|
|
return { players: state.players.map(seat => ({ ...seat, ally: quoted.allies[seat.id] })), offers: quoted.offers };
|
|
}
|
|
function invitations(): AllianceExpressionContext {
|
|
return action(action(context(), 'partner-a', 'respondent'), 'partner-b', 'respondent');
|
|
}
|
|
function execute(state: AllianceExpressionContext, plan: AllianceAction[]): AllianceExpressionContext {
|
|
return plan.reduce((current, step) => action(current, 'respondent', step.target), state);
|
|
}
|
|
const finalAlly = (state: AllianceExpressionContext) => state.players.find(seat => seat.id === 'respondent')!.ally;
|
|
function freeze(value: unknown): void {
|
|
if (!value || typeof value !== 'object') return;
|
|
for (const child of Object.values(value as Record<string, unknown>)) freeze(child);
|
|
Object.freeze(value);
|
|
}
|
|
|
|
void test('alliance expression claims retain full nesting and exact public partner text without private metadata', () => {
|
|
const claim = { turn: 2, claim: group('and', either(), leaf('partner-a')) };
|
|
const parsed = parseAllianceExpressionClaim(claim);
|
|
assert.deepEqual(parsed, claim); assert.notEqual(parsed.claim, claim.claim);
|
|
assert.equal(allianceExpressionText(parsed.claim, id => id === 'partner-a' ? 'Atreides' : 'Emperor'),
|
|
'((be allied with Atreides when this Nexus ends OR be allied with Emperor when this Nexus ends) AND be allied with Atreides when this Nexus ends)');
|
|
for (const turn of [0, -1, 1.5, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, '2', null])
|
|
assert.throws(() => parseAllianceExpressionClaim({ ...claim, turn }), AllianceExpressionPromiseError);
|
|
for (const extra of [{ source: 'private-source' }, { event: 'private-event' }, { answer: true }])
|
|
assert.throws(() => parseAllianceExpressionClaim({ ...claim, ...extra }), AllianceExpressionPromiseError);
|
|
});
|
|
|
|
void test('full alliance parsing enforces all branches, two-child groups, sixteen leaves and four grouping levels', () => {
|
|
assert.equal(ALLIANCE_EXPRESSION_MAX_LEAVES, 16); assert.equal(ALLIANCE_EXPRESSION_MAX_DEPTH, 4);
|
|
const nested = (depth: number): AllianceExpression => depth === 0 ? leaf('partner-a') :
|
|
group('and', nested(depth - 1), leaf('partner-b'));
|
|
assert.deepEqual(parseAllianceExpression(nested(4)), nested(4));
|
|
assert.deepEqual(parseAllianceExpression(group('or', ...Array.from({ length: 16 }, () => leaf('partner-a')))),
|
|
group('or', ...Array.from({ length: 16 }, () => leaf('partner-a'))));
|
|
const invalid: unknown[] = [null, [], {}, { kind: 'partner', partner: '' }, { kind: 'partner', partner: ' partner-a' },
|
|
{ kind: 'partner', partner: 'partner-a ' }, { kind: 'partner', partner: null },
|
|
{ kind: 'partner', partner: 'partner-a', source: 'private' }, { kind: 'and', terms: [] },
|
|
{ kind: 'or', terms: [leaf('partner-a')] }, { kind: 'not', terms: [leaf('partner-a'), leaf('partner-b')] },
|
|
{ kind: 'or', terms: [leaf('partner-a'), { kind: 'partner', partner: 2 }] },
|
|
group('or', ...Array.from({ length: 17 }, () => leaf('partner-a'))), nested(5),
|
|
group('and', ...Array.from({ length: 9 }, () => either()))];
|
|
for (const value of invalid) assert.throws(() => parseAllianceExpression(value), AllianceExpressionPromiseError);
|
|
const hiddenInvalid = { kind: 'or', terms: [leaf('partner-a'), { kind: 'partner', partner: '' }] } as AllianceExpression;
|
|
assert.throws(() => evaluateAllianceExpression(hiddenInvalid, 'partner-a'), AllianceExpressionPromiseError);
|
|
});
|
|
|
|
void test('positive final truth uses one native partner and a whole No negates the whole nested question', () => {
|
|
const both = group('and', leaf('partner-a'), leaf('partner-b'));
|
|
for (const ally of [null, 'partner-a', 'partner-b', 'partner-c']) {
|
|
assert.equal(evaluateAllianceExpression(both, ally), false);
|
|
assert.equal(allianceExpressionPromisesMatch([promise('no-both', both, { answer: false })], 'respondent', opportunity(), ally), true);
|
|
assert.equal(evaluateAllianceExpression(either(), ally), ally === 'partner-a' || ally === 'partner-b');
|
|
}
|
|
const nested = group('and', either(), group('or', leaf('partner-b'), leaf('partner-c')));
|
|
assert.equal(evaluateAllianceExpression(nested, 'partner-a'), false);
|
|
assert.equal(evaluateAllianceExpression(nested, 'partner-b'), true);
|
|
assert.equal(evaluateAllianceExpression(group('and', leaf('partner-a'), leaf('partner-a')), 'partner-a'), true);
|
|
});
|
|
|
|
void test('truth-compatible outcomes keep current membership first and intersect scalar and whole answers', () => {
|
|
const state = action(invitations(), 'respondent', 'partner-b');
|
|
const records = [promise('either')];
|
|
assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), state), ['partner-b', 'partner-a']);
|
|
assert.deepEqual(allianceExpressionOutcomes([scalar('no-b', 'partner-b', false)], records, 'respondent', opportunity(), state), ['partner-a']);
|
|
assert.deepEqual(allianceExpressionOutcomes([], [promise('whole-no', either(), { answer: false })],
|
|
'respondent', opportunity(), context()), [null, 'partner-c', 'asker']);
|
|
assert.deepEqual(allianceExpressionOutcomes([], records, 'absent', opportunity(), state), []);
|
|
});
|
|
|
|
void test('two actual incoming invitations produce both canonical owned OR routes and leave manual alternatives available', () => {
|
|
const state = invitations(); const records = [promise('either')];
|
|
const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), state);
|
|
assert.deepEqual(plans, [[{ type: 'alliance', target: 'partner-a' }], [{ type: 'alliance', target: 'partner-b' }]]);
|
|
for (const plan of plans) {
|
|
const completed = execute(state, plan);
|
|
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(completed)), true);
|
|
const selected = finalAlly(completed)!;
|
|
assert.equal(completed.players.find(seat => seat.id === selected)!.ally, 'respondent');
|
|
}
|
|
const manual = action(state, 'respondent', 'partner-b');
|
|
assert.equal(finalAlly(manual), 'partner-b');
|
|
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(manual)), true);
|
|
assert.equal(records[0].fulfilled, undefined, 'No branch is stored or fulfilled before actual Nexus close');
|
|
});
|
|
|
|
void test('missing, own, withdrawn or occupied-partner offers cannot invent foreign consent', () => {
|
|
const records = [promise('either')];
|
|
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), context()), []);
|
|
const ownOffer = action(context(), 'respondent', 'partner-a');
|
|
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), ownOffer), []);
|
|
const oneWithdrawn = action(invitations(), 'partner-a', null);
|
|
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), oneWithdrawn),
|
|
[[{ type: 'alliance', target: 'partner-b' }]]);
|
|
const bothWithdrawn = action(oneWithdrawn, 'partner-b', null);
|
|
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), bothWithdrawn), []);
|
|
const occupied = action(action(action(context(), 'partner-a', 'respondent'), 'partner-c', 'partner-a'), 'partner-a', 'partner-c');
|
|
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), occupied), []);
|
|
});
|
|
|
|
void test('whole OR No can require exactly None while allowing an interim pair until the owned break', () => {
|
|
const anyPartner = group('or', ...seats.filter(id => id !== 'respondent').map(leaf));
|
|
const records = [promise('no-one', anyPartner, { answer: false })];
|
|
const paired = action(invitations(), 'respondent', 'partner-a');
|
|
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(paired)), false);
|
|
assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), paired), [null]);
|
|
const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), paired);
|
|
assert.deepEqual(plans, [[{ type: 'alliance', target: null }]]);
|
|
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(execute(paired, plans[0]))), true);
|
|
assert.equal(records[0].fulfilled, undefined); assert.equal(records[0].released, undefined);
|
|
});
|
|
|
|
void test('whole AND No retains a matching held pair and all other currently owned final routes', () => {
|
|
const state = action(invitations(), 'respondent', 'partner-a');
|
|
const records = [promise('not-both', group('and', leaf('partner-a'), leaf('partner-b')), { answer: false })];
|
|
const plans = findAllianceExpressionPlans([], records, 'respondent', opportunity(), state);
|
|
assert.deepEqual(plans, [[], [{ type: 'alliance', target: null }],
|
|
[{ type: 'alliance', target: null }, { type: 'alliance', target: 'partner-b' }]]);
|
|
assert.deepEqual(allianceExpressionOutcomes([], records, 'respondent', opportunity(), state),
|
|
['partner-a', null, 'partner-b', 'partner-c', 'asker']);
|
|
for (const plan of plans) assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), finalAlly(execute(state, plan))), true);
|
|
});
|
|
|
|
void test('joint scalar and whole-expression contradictions have no outcome or invented branch', () => {
|
|
const state = invitations();
|
|
const cases = [
|
|
{ scalars: [scalar('yes-a', 'partner-a', true)], expressions: [promise('no-either', either(), { answer: false })] },
|
|
{ scalars: [scalar('no-a', 'partner-a', false)], expressions: [promise('yes-a-twice', group('and', leaf('partner-a'), leaf('partner-a')))] },
|
|
{ scalars: [], expressions: [promise('yes-both', group('and', leaf('partner-a'), leaf('partner-b')))] },
|
|
{ scalars: [], expressions: [promise('yes-either'), promise('no-either', either(), { answer: false })] },
|
|
];
|
|
for (const selected of cases) {
|
|
assert.deepEqual(allianceExpressionOutcomes(selected.scalars, selected.expressions, 'respondent', opportunity(), state), []);
|
|
assert.deepEqual(findAllianceExpressionPlans(selected.scalars, selected.expressions, 'respondent', opportunity(), state), []);
|
|
}
|
|
});
|
|
|
|
void test('no live expression preserves the original scalar planner exactly, including no-op and absent-owner behavior', () => {
|
|
const old = [promise('old', either(), { source: 'older-nexus' }), promise('released', either(), { released: true })];
|
|
for (const state of [context(), invitations(), action(invitations(), 'respondent', 'partner-a')])
|
|
for (const records of [[], [scalar('yes-a', 'partner-a', true)], [scalar('no-a', 'partner-a', false)]]) {
|
|
const expected = findAlliancePromisePlan(records, 'respondent', opportunity(), state);
|
|
assert.deepEqual(findAllianceExpressionPlans(records, old, 'respondent', opportunity(), state), expected === null ? [] : [expected]);
|
|
}
|
|
assert.deepEqual(findAllianceExpressionPlans([], [], 'absent', opportunity(), context()), []);
|
|
});
|
|
|
|
void test('original turn, Nexus source, owner and terminal state isolate the live whole-expression records', () => {
|
|
const live = promise('live');
|
|
const records = [live, promise('old-turn', leaf('partner-c'), { turn: 1 }),
|
|
promise('other-source', leaf('partner-c'), { source: 'other-worm-same-turn' }),
|
|
promise('other-owner', leaf('partner-c'), { player: 'partner-a' }),
|
|
promise('fulfilled', leaf('partner-c'), { fulfilled: true }), promise('released', leaf('partner-c'), { released: true })];
|
|
assert.deepEqual(liveAllianceExpressionPromises(records, 'respondent', opportunity()), [live]);
|
|
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), 'partner-a'), true);
|
|
assert.equal(allianceExpressionPromisesMatch(records, 'respondent', opportunity(), 'partner-c'), false);
|
|
assert.deepEqual(findAllianceExpressionPlans([], records, 'respondent', opportunity(), invitations()),
|
|
[[{ type: 'alliance', target: 'partner-a' }], [{ type: 'alliance', target: 'partner-b' }]]);
|
|
});
|
|
|
|
void test('expression records validate source, unique event, every personal partner and terminal flags without mutating inputs', () => {
|
|
const records = [promise('live'), promise('done', leaf('partner-a'), { fulfilled: true }),
|
|
promise('released-no', leaf('asker'), { answer: false, released: true })];
|
|
const state = invitations(), scalars = [scalar('no-c', 'partner-c', false)];
|
|
const before = structuredClone({ records, state, scalars });
|
|
freeze(records); freeze(state); freeze(scalars);
|
|
assert.doesNotThrow(() => validateAllianceExpressionPromises(records, seats));
|
|
assert.doesNotThrow(() => validateAllianceExpressionPromises(JSON.parse(JSON.stringify(records)) as AllianceExpressionPromise[], seats));
|
|
parseAllianceExpressionClaim({ turn: 2, claim: records[0].claim });
|
|
allianceExpressionText(records[0].claim); evaluateAllianceExpression(records[0].claim, 'partner-a');
|
|
allianceExpressionOutcomes(scalars, records, 'respondent', opportunity(), state);
|
|
findAllianceExpressionPlans(scalars, records, 'respondent', opportunity(), state);
|
|
assert.deepEqual({ records, state, scalars }, before);
|
|
const base = promise('event');
|
|
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
|
|
...[{ event: '' }, { event: ' event' }, { source: '' }, { source: ' source' },
|
|
{ player: 'missing' }, { asker: 'missing' }, { asker: 'respondent' }, { answer: 'yes' },
|
|
{ turn: 0 }, { fulfilled: 1 }, { released: 'true' }, { fulfilled: true, released: true },
|
|
{ claim: group('or', leaf('partner-a'), leaf('missing')) },
|
|
{ claim: group('or', leaf('partner-a'), leaf('respondent')) }].map(extra => [{ ...base, ...extra }])];
|
|
for (const value of invalid)
|
|
assert.throws(() => validateAllianceExpressionPromises(value as AllianceExpressionPromise[], seats), AllianceExpressionPromiseError);
|
|
assert.deepEqual({ records, state, scalars }, before);
|
|
});
|