240 lines
13 KiB
TypeScript
240 lines
13 KiB
TypeScript
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> = {}): TraitorDecisionContext {
|
|
return {
|
|
turn: 4, battle: 'original-public-battle', player: 'harkonnen',
|
|
beneficiary: 'atreides', target: 'emperor', leader: 'aramsham',
|
|
canReveal: true, ...overrides,
|
|
};
|
|
}
|
|
|
|
function promise(overrides: Partial<TraitorDecisionPromise> = {}): 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<TraitorDecisionPromise>[] = [
|
|
{ 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<string, unknown>;
|
|
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);
|
|
});
|