Files
Dune/tests/sapho-truth-order.test.ts

118 lines
7.5 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
SaphoTruthOrderError, createSaphoTruthOrder, passSaphoTruthOrder,
quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder,
} from '../game/sapho-truth-order';
const original = [
{ player: 'a', card: 'a1', source: 'printed' },
{ player: 'a', card: 'a2', source: 'shrine' },
{ player: 'b', card: 'b1', source: 'printed' },
{ player: 'c', card: 'c1', source: 'printed' },
];
const frame = () => createSaphoTruthOrder(original, 'opaque-event')!;
void test('the public window is created from distinct committed holders, never their hidden hands', () => {
assert.equal(createSaphoTruthOrder([], 'empty'), null);
assert.equal(createSaphoTruthOrder(original.slice(0, 2), 'one-holder'), null);
assert.deepEqual(frame(), { event: 'opaque-event', revision: 0, holders: ['a', 'b', 'c'], passed: [] });
assert.deepEqual(quoteSaphoTruthOrder(frame(), original, 'a'), { event: 'opaque-event', revision: 0, modes: ['last'] });
assert.deepEqual(quoteSaphoTruthOrder(frame(), original, 'b'), { event: 'opaque-event', revision: 0, modes: ['first', 'last'] });
assert.deepEqual(quoteSaphoTruthOrder(frame(), original, 'c'), { event: 'opaque-event', revision: 0, modes: ['first'] });
});
void test('first and last move the entire group, preserving every original row and internal card order', () => {
const before = structuredClone(original), start = frame();
const first = reorderSaphoTruthOrder(start, original, { event: start.event, revision: 0, player: 'b', mode: 'first' });
assert.deepEqual(first.queue.map(row => row.card), ['b1', 'a1', 'a2', 'c1']);
assert.deepEqual(first.frame, { event: 'opaque-event', revision: 1, holders: ['a', 'b', 'c'], passed: ['b'], protectedFirst: 'b' });
assert.equal(first.done, false);
assert.deepEqual(original, before);
const last = reorderSaphoTruthOrder(first.frame, first.queue, { event: start.event, revision: 1, player: 'a', mode: 'last' });
assert.deepEqual(last.queue.map(row => row.card), ['b1', 'c1', 'a1', 'a2']);
assert.deepEqual(last.queue.filter(row => row.player === 'a'), before.slice(0, 2));
assert.deepEqual(last.frame.passed, ['b', 'a']);
assert.deepEqual(last.frame.holders, ['a', 'b', 'c']);
});
void test('every public holder may pass independently, and only the last response completes the window', () => {
const first = passSaphoTruthOrder(frame(), original, { event: 'opaque-event', revision: 0, player: 'c' });
assert.equal(first.done, false);
const second = passSaphoTruthOrder(first.frame, first.queue, { event: 'opaque-event', revision: 1, player: 'a' });
assert.equal(second.done, false);
const third = passSaphoTruthOrder(second.frame, second.queue, { event: 'opaque-event', revision: 2, player: 'b' });
assert.equal(third.done, true);
assert.deepEqual(third.queue, original);
assert.deepEqual(third.frame.passed, ['c', 'a', 'b']);
});
void test('expired events, stale revisions and duplicate or outsider responses leave the input unchanged', () => {
const initial = frame(), first = passSaphoTruthOrder(initial, original, { event: 'opaque-event', revision: 0, player: 'a' });
const before = structuredClone(first);
for (const request of [
{ event: 'other', revision: 1, player: 'b' },
{ event: 'opaque-event', revision: 0, player: 'b' },
{ event: 'opaque-event', revision: 1, player: 'a' },
{ event: 'opaque-event', revision: 1, player: 'outsider' },
]) assert.throws(() => passSaphoTruthOrder(first.frame, first.queue, request), SaphoTruthOrderError);
assert.deepEqual(first, before);
});
void test('no-op choices and malformed orders reject without consuming an opportunity', () => {
const initial = frame(), before = structuredClone(initial);
for (const request of [
{ event: initial.event, revision: 0, player: 'a', mode: 'first' as const },
{ event: initial.event, revision: 0, player: 'c', mode: 'last' as const },
]) assert.throws(() => reorderSaphoTruthOrder(initial, original, request), SaphoTruthOrderError);
assert.deepEqual(initial, before);
assert.throws(() => createSaphoTruthOrder([original[0], original[2], original[1]], 'event'), /together/);
assert.throws(() => validateSaphoTruthOrder({ ...initial, passed: ['a'], revision: 0 }, original), /revision/);
assert.throws(() => validateSaphoTruthOrder({ ...initial, holders: ['a', 'b', 'd'] }, original), /original Truth holder/);
assert.throws(() => validateSaphoTruthOrder({ ...initial, passed: ['outsider'], revision: 1 }, original), /original holders/);
});
void test('JSON round-trip preserves the event, revision and grouped ordering without private data in the frame', () => {
const initial = frame(), moved = reorderSaphoTruthOrder(initial, original, { event: initial.event, revision: 0, player: 'c', mode: 'first' });
const restored = JSON.parse(JSON.stringify(moved)) as typeof moved;
assert.doesNotMatch(JSON.stringify(restored.frame), /a1|a2|b1|c1|shrine/);
validateSaphoTruthOrder(restored.frame, restored.queue);
assert.deepEqual(quoteSaphoTruthOrder(restored.frame, restored.queue, 'a'), { event: 'opaque-event', revision: 1, modes: ['last'] });
});
void test('an earlier first choice is protected when another holder later obtains and plays Sapho', () => {
const initial = frame();
const first = reorderSaphoTruthOrder(initial, original, { event: initial.event, revision: 0, player: 'b', mode: 'first' });
// A second physical use is possible after another player claims the same
// discarded Sapho through Semuta. It may not displace the first holder.
assert.deepEqual(quoteSaphoTruthOrder(first.frame, first.queue, 'c').modes, []);
assert.throws(() => reorderSaphoTruthOrder(first.frame, first.queue,
{ event: initial.event, revision: 1, player: 'c', mode: 'first' }), /protects/);
assert.deepEqual(quoteSaphoTruthOrder(first.frame, first.queue, 'a').modes, ['last']);
const opposite = reorderSaphoTruthOrder(first.frame, first.queue,
{ event: initial.event, revision: 1, player: 'a', mode: 'last' });
assert.deepEqual(opposite.queue.map(row => row.player), ['b','c','a','a']);
assert.equal(opposite.frame.protectedFirst, 'b');
assert.equal(opposite.frame.protectedLast, 'a');
assert.deepEqual(quoteSaphoTruthOrder(opposite.frame, opposite.queue, 'c').modes, []);
validateSaphoTruthOrder(opposite.frame, opposite.queue);
});
void test('an earlier last choice remains protected, and legacy frames need no protection fields', () => {
const initial = frame();
const last = reorderSaphoTruthOrder(initial, original, { event: initial.event, revision: 0, player: 'b', mode: 'last' });
assert.equal(last.frame.protectedLast, 'b');
assert.deepEqual(last.queue.map(row => row.player), ['a','a','c','b']);
assert.deepEqual(quoteSaphoTruthOrder(last.frame,last.queue,'c').modes, ['first']);
assert.throws(() => reorderSaphoTruthOrder(last.frame,last.queue,
{event:initial.event,revision:1,player:'c',mode:'last'}), /protects/);
const first = reorderSaphoTruthOrder(last.frame,last.queue,
{event:initial.event,revision:1,player:'c',mode:'first'});
assert.deepEqual(first.queue.map(row => row.player), ['c','a','a','b']);
assert.equal(first.frame.protectedFirst,'c'); assert.equal(first.frame.protectedLast,'b');
assert.throws(() => validateSaphoTruthOrder(first.frame,[...first.queue].reverse()), /together|protected/);
const legacy = JSON.parse(JSON.stringify(initial)) as typeof initial;
assert.equal(legacy.protectedFirst,undefined); assert.equal(legacy.protectedLast,undefined);
validateSaphoTruthOrder(legacy,original);
});