import assert from 'node:assert/strict'; import test from 'node:test'; import { TraitorDecisionError, bindTraitorDecisionPromise, reconcileTraitorDecisionPromises, settleTraitorDecisionPromises, traitorDecisionChoices, validateTraitorDecisionPromise, type TraitorDecisionContext, type TraitorDecisionPromise, } from '../game/traitor-decision-promises'; function context(overrides: Partial = {}): TraitorDecisionContext { return { turn: 4, battle: 'original-public-battle', player: 'harkonnen', beneficiary: 'atreides', target: 'emperor', leader: 'aramsham', canReveal: true, ...overrides, }; } function promise(overrides: Partial = {}): TraitorDecisionPromise { return { turn: 4, battle: 'original-public-battle', player: 'harkonnen', beneficiary: 'atreides', target: 'emperor', leader: 'aramsham', asker: 'beneGesserit', answer: true, ...overrides, }; } void test('native eligibility supplies ordered declaration choices and a submitted decision has none', () => { assert.deepEqual(traitorDecisionChoices(context(), []), [false, true]); assert.deepEqual(traitorDecisionChoices(context({ canReveal: false }), []), [false]); assert.deepEqual(traitorDecisionChoices(context({ leader: null, canReveal: false }), []), [false]); for (const canReveal of [false, true]) { assert.deepEqual(traitorDecisionChoices(context({ canReveal, submitted: true }), []), []); } assert.deepEqual(traitorDecisionChoices(context({ submitted: false }), []), []); }); void test('compatible definite answers bind independently while contradiction and impossible Yes reject without mutation', () => { const ctx = context(); const first = bindTraitorDecisionPromise(ctx, [], 'beneGesserit', true); const before = structuredClone({ ctx, first }); const second = bindTraitorDecisionPromise(ctx, first, 'fremen', true); assert.deepEqual(second, [promise(), promise({ asker: 'fremen' })]); assert.deepEqual(traitorDecisionChoices(ctx, second), [true]); assert.deepEqual({ ctx, first }, before); assert.notEqual(second, first); assert.notEqual(second[0], first[0]); assert.throws(() => bindTraitorDecisionPromise(ctx, first, 'fremen', false), TraitorDecisionError); assert.deepEqual({ ctx, first }, before); const no = bindTraitorDecisionPromise(context({ canReveal: false }), [], 'beneGesserit', false); assert.deepEqual(traitorDecisionChoices(context(), no), [false]); assert.throws(() => bindTraitorDecisionPromise(context({ canReveal: false }), [], 'fremen', true), TraitorDecisionError); assert.throws(() => bindTraitorDecisionPromise(context({ submitted: true }), [], 'fremen', false), TraitorDecisionError); }); void test('only the complete original public source constrains a current declaration', () => { const changes: Partial[] = [ { turn: 3 }, { battle: 'other-battle' }, { player: 'other-voter' }, { beneficiary: 'other-beneficiary' }, { target: 'other-target' }, { leader: 'other-leader' }, { leader: null }, { fulfilled: true }, { released: true }, ]; for (const change of changes) { const ledger = [promise(change)]; const before = structuredClone(ledger); assert.deepEqual(traitorDecisionChoices(context(), ledger), [false, true]); assert.deepEqual(reconcileTraitorDecisionPromises(context({ canReveal: false }), ledger), ledger); assert.deepEqual(ledger, before); } assert.deepEqual(traitorDecisionChoices(context(), [promise(), promise({ asker: 'fremen', answer: false })]), []); }); void test('settlement validates before the actual assignment and fulfills either matching declaration exactly once', () => { for (const call of [false, true]) { const ctx = context(); const ledger = [ promise({ answer: call }), promise({ answer: call, asker: 'fremen' }), promise({ battle: 'older-battle', answer: !call }), promise({ beneficiary: 'other-beneficiary', answer: !call }), promise({ released: true, answer: !call }), promise({ fulfilled: true, answer: !call }), ]; const before = structuredClone({ ctx, ledger }); assert.throws(() => settleTraitorDecisionPromises(ctx, ledger, !call), TraitorDecisionError); assert.deepEqual({ ctx, ledger }, before); const settled = settleTraitorDecisionPromises(ctx, ledger, call); assert.equal(settled[0].fulfilled, true); assert.equal(settled[1].fulfilled, true); assert.deepEqual(settled.slice(2), ledger.slice(2)); assert.deepEqual({ ctx, ledger }, before); assert.equal(ctx.submitted, undefined, 'The caller still owns actual declaration assignment'); assert.throws(() => settleTraitorDecisionPromises({ ...ctx, submitted: true }, settled, call), TraitorDecisionError); } assert.throws(() => settleTraitorDecisionPromises(context({ canReveal: false }), [], true), TraitorDecisionError); assert.deepEqual(settleTraitorDecisionPromises(context({ canReveal: false }), [promise({ answer: false })], false), [promise({ answer: false, fulfilled: true })]); }); void test('native impossibility releases only a current live Yes and keeps No, historical sources and fulfilled decisions', () => { const ctx = context({ canReveal: false }); const ledger = [ promise(), promise({ answer: false, asker: 'fremen' }), promise({ battle: 'another-original-source' }), promise({ fulfilled: true }), promise({ released: true }), ]; const before = structuredClone({ ctx, ledger }); assert.deepEqual(traitorDecisionChoices(ctx, ledger), []); const reconciled = reconcileTraitorDecisionPromises(ctx, ledger); assert.deepEqual(reconciled, [promise({ released: true }), ...ledger.slice(1)]); assert.deepEqual(traitorDecisionChoices(ctx, reconciled), [false]); assert.deepEqual(reconcileTraitorDecisionPromises(ctx, reconciled), reconciled); assert.deepEqual(reconcileTraitorDecisionPromises(context(), ledger), ledger); assert.deepEqual({ ctx, ledger }, before); }); void test('an accepted declaration remains fulfilled if its later Traitor effect is prevented', () => { const accepted = bindTraitorDecisionPromise(context(), [], 'beneGesserit', true); const declared = settleTraitorDecisionPromises(context(), accepted, true); const before = structuredClone(declared); const afterPrevention = reconcileTraitorDecisionPromises( context({ canReveal: false, submitted: true }), declared, ); assert.equal(afterPrevention[0].answer, true); assert.equal(afterPrevention[0].fulfilled, true); assert.equal(afterPrevention[0].released, undefined); assert.deepEqual(afterPrevention, before); assert.deepEqual(traitorDecisionChoices(context({ canReveal: false, submitted: true }), afterPrevention), []); assert.deepEqual(accepted, [promise()]); }); void test('promise validation accepts only the bounded public source and typed flags, including a null leader', () => { assert.equal(validateTraitorDecisionPromise(promise()), true); assert.equal(validateTraitorDecisionPromise(promise({ leader: null, answer: false })), true); assert.equal(validateTraitorDecisionPromise(promise({ fulfilled: false, released: false })), true); assert.equal(validateTraitorDecisionPromise(promise({ fulfilled: true })), true); assert.equal(validateTraitorDecisionPromise(promise({ released: true })), true); assert.equal(validateTraitorDecisionPromise(promise({ event: 'mixed-traitor-event' })), true); for (const invalid of [ null, [], 'promise', 4, {}, { ...promise(), turn: 0 }, { ...promise(), turn: 1.5 }, { ...promise(), turn: Number.MAX_SAFE_INTEGER + 1 }, { ...promise(), battle: '' }, { ...promise(), player: ' ' }, { ...promise(), beneficiary: null }, { ...promise(), target: 4 }, { ...promise(), leader: '' }, { ...promise(), asker: '' }, { ...promise(), answer: 'yes' }, { ...promise(), fulfilled: 1 }, { ...promise(), released: 'false' }, { ...promise(), event: '' }, { ...promise(), event: ' ' }, { ...promise(), event: null }, { ...promise(), event: 7 }, { ...promise(), event: undefined }, { ...promise(), canReveal: true }, { ...promise(), submitted: false }, { ...promise(), card: 'private-card' }, { ...promise(), hand: [] }, ]) { assert.equal(validateTraitorDecisionPromise(invalid), false); } for (const key of ['turn', 'battle', 'player', 'beneficiary', 'target', 'leader', 'asker', 'answer']) { const missing = { ...promise() } as Record; delete missing[key]; assert.equal(validateTraitorDecisionPromise(missing), false, key); } }); void test('an optional mixed event links only its new native promise and survives cloning, fulfillment and release', () => { const ctx = context(); const historical = promise({ battle: 'older-battle', event: 'older-mixed-event' }); const ledger = [historical]; const before = structuredClone({ ctx, ledger }); for (const answer of [false, true]) { const accepted = bindTraitorDecisionPromise(ctx, ledger, 'fremen', answer, 'mixed-traitor-event'); assert.deepEqual(accepted, [historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event' })]); assert.notEqual(accepted[0], historical); assert.deepEqual(settleTraitorDecisionPromises(ctx, accepted, answer), [ historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event', fulfilled: true }), ]); assert.deepEqual(reconcileTraitorDecisionPromises(context({ canReveal: false }), accepted), [ historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event', ...(answer ? { released: true } : {}) }), ]); assert.deepEqual(accepted, [historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event' })]); } assert.deepEqual({ ctx, ledger }, before); }); void test('omitted or undefined event arguments retain the original pure shape and invalid event links reject without mutation', () => { const ctx = context(), ledger = [promise()]; const before = structuredClone({ ctx, ledger }); for (const bound of [ bindTraitorDecisionPromise(ctx, ledger, 'fremen', true), bindTraitorDecisionPromise(ctx, ledger, 'fremen', true, undefined), ]) { assert.deepEqual(bound, [promise(), promise({ asker: 'fremen' })]); assert.equal(Object.hasOwn(bound[1], 'event'), false); } for (const event of ['', ' ', null, 7, false, {}]) { assert.throws(() => bindTraitorDecisionPromise(ctx, ledger, 'fremen', true, event as string), TraitorDecisionError); assert.deepEqual({ ctx, ledger }, before); } }); void test('operational guards reject malformed contexts or ledger values with the named error and unchanged input', () => { for (const malformed of [ null, [], { ...context(), turn: 0 }, { ...context(), battle: '' }, { ...context(), canReveal: 'yes' }, { ...context(), submitted: 1 }, { ...context(), leader: undefined }, ]) { const ctx = malformed as unknown as TraitorDecisionContext; assert.throws(() => traitorDecisionChoices(ctx, []), (error: unknown) => error instanceof TraitorDecisionError && error.name === 'TraitorDecisionError'); } const ledger = [{ ...promise(), privateCard: 'secret' }] as unknown as TraitorDecisionPromise[]; const before = structuredClone(ledger); for (const operation of [ () => traitorDecisionChoices(context(), ledger), () => bindTraitorDecisionPromise(context(), ledger, 'fremen', true), () => settleTraitorDecisionPromises(context(), ledger, true), () => reconcileTraitorDecisionPromises(context({ canReveal: false }), ledger), ]) assert.throws(operation, TraitorDecisionError); assert.deepEqual(ledger, before); assert.throws(() => bindTraitorDecisionPromise(context(), [], '', true), TraitorDecisionError); assert.throws(() => bindTraitorDecisionPromise(context(), [], 'fremen', 'yes' as unknown as boolean), TraitorDecisionError); assert.throws(() => settleTraitorDecisionPromises(context(), [], 1 as unknown as boolean), TraitorDecisionError); }); void test('binding copies only the original public decision fields and never reads private native matching or power data', () => { const ctx = context(); for (const field of ['card', 'hand', 'traitors', 'kh', 'leaders', 'faction', 'ally', 'nativeContext', 'response']) { Object.defineProperty(ctx, field, { enumerable: true, get() { throw new Error(`Pure Traitor decision read private native ${field}`); }, }); } const bound = bindTraitorDecisionPromise(ctx, [], 'beneGesserit', true); assert.deepEqual(bound, [promise()]); assert.deepEqual(Object.keys(bound[0]).sort(), [ 'answer', 'asker', 'battle', 'beneficiary', 'leader', 'player', 'target', 'turn', ]); assert.deepEqual(traitorDecisionChoices(ctx, bound), [true]); assert.equal(settleTraitorDecisionPromises(ctx, bound, true)[0].fulfilled, true); assert.equal(reconcileTraitorDecisionPromises(ctx, bound)[0].released, undefined); const privateReceipt = promise(); Object.defineProperty(privateReceipt, 'card', { enumerable: true, get() { throw new Error('Validation inspected private card payload'); }, }); assert.equal(validateTraitorDecisionPromise(privateReceipt), false); });