import test from 'node:test'; import assert from 'node:assert/strict'; import { leaders } from '../game/cards'; import { traitorDeck } from '../game/traitors'; import { validateNexusTraitorSnapshot, type NexusTraitorSnapshot, } from '../game/nexus-traitor-exchange'; import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFaceDancerReceipt, } from '../game/nexus-face-dancers'; const universe = traitorDeck( ['tleilaxu', 'harkonnen', 'guild'].map((f) => ({ leaders: leaders(f as 'tleilaxu' | 'harkonnen' | 'guild'), })), true, ); const event = { event: 'tleilaxu-nexus-1', owner: 't', turn: 3, phase: 6 }; const reload = (value: T): T => JSON.parse(JSON.stringify(value)) as T; function fixture(revealed = [true, false, true]): NexusTraitorSnapshot { return { reserve: universe.slice(5), players: [ { id: 't', faction: 'tleilaxu', traitors: [], faceDancers: universe .slice(0, 3) .map((leader, i) => ({ leader, revealed: revealed[i] })), }, { id: 'h', faction: 'harkonnen', traitors: [universe[3]] }, { id: 'g', faction: 'guild', traitors: [universe[4]] }, ], }; } void test('one, two or three revealed Face Dancers are replaced as a whole batch before their cards return to the reserve', () => { for (const count of [1, 2, 3]) { const state = fixture([0, 1, 2].map((i) => i < count)), before = reload(state); const result = replaceNexusFaceDancers(state, universe, event, () => 0.999); assert.deepEqual(state, before); assert.deepEqual(result.receipt.replaced, universe.slice(0, count)); assert.deepEqual(result.receipt.drawn, state.reserve.slice(0, count)); assert.deepEqual(result.state.reserve, [ ...state.reserve.slice(count), ...universe.slice(0, count), ]); assert.deepEqual(result.state.players[0].faceDancers, [ ...state.players[0].faceDancers!.filter((c) => !c.revealed), ...state.reserve .slice(0, count) .map((leader) => ({ leader, revealed: false })), ]); assert.deepEqual(result.state.players.slice(1), state.players.slice(1)); assert.equal( result.state.players[0].faceDancers!.some((c) => result.receipt.replaced.includes(c.leader), ), false, ); validateNexusTraitorSnapshot(result.state, universe); } }); void test('replacement order preserves unrevealed originals and never offers a subset or returning new cards', () => { const state = fixture([true, false, true]); const result = replaceNexusFaceDancers(state, universe, event, () => 0); assert.deepEqual( result.state.players[0].faceDancers![0], state.players[0].faceDancers![1], ); assert.equal(result.state.players[0].faceDancers!.length, 3); assert.equal( result.state.players[0].faceDancers!.every((c) => c.revealed === false), true, ); assert.equal(result.receipt.stage, 'complete'); assert.throws(() => replaceNexusFaceDancers( state, universe, { ...event, cards: [universe[0]] } as typeof event, () => 0, ), ); assert.throws(() => replaceNexusFaceDancers( result.state, universe, { ...event, event: 'new-event' }, () => { throw Error('No revealed cards must not shuffle'); }, ), ); }); void test('zero revealed, undersized reserve and wrong faction reject before randomness or mutation', () => { const zero = fixture([false, false, false]); const short = fixture(); short.players[1].traitors.push(...short.reserve.splice(1)); for (const [state, input] of [ [zero, event], [short, event], [fixture(), { ...event, owner: 'h' }], [fixture(), { ...event, owner: 'missing' }], ] as Array<[NexusTraitorSnapshot, typeof event]>) { const before = reload(state); let calls = 0; assert.throws(() => replaceNexusFaceDancers(state, universe, input, () => { calls++; return 0; }), ); assert.equal(calls, 0); assert.deepEqual(state, before); } }); void test('even an exactly sized reserve draws entirely before set-aside cards are shuffled', () => { const state = fixture(); state.players[1].traitors.push(...state.reserve.splice(2)); const drawn = [...state.reserve]; const result = replaceNexusFaceDancers(state, universe, event, () => 0); assert.deepEqual(result.receipt.drawn, drawn); assert.deepEqual( [...result.state.reserve].sort(), [universe[0], universe[2]].sort(), ); assert.deepEqual( result.state.players[0].faceDancers!.slice(1).map((c) => c.leader), drawn, ); validateNexusFaceDancerHistory(universe, result.receipt); }); void test('validated injected randomness shuffles only the remaining reserve and old revealed identities', () => { const state = fixture(), before = reload(state); let calls = 0; const result = replaceNexusFaceDancers(state, universe, event, () => { calls++; return 0; }); assert.equal(calls, state.reserve.length - 1); assert.deepEqual( [...result.state.reserve].sort(), [...state.reserve.slice(2), universe[0], universe[2]].sort(), ); assert.deepEqual( result, replaceNexusFaceDancers(state, universe, event, () => 0), ); for (const invalid of [-1, 1, NaN, Infinity]) assert.throws(() => replaceNexusFaceDancers(state, universe, event, () => invalid), ); assert.deepEqual(state, before); }); void test('history validates JSON independently of later legitimate live custody and output mutations do not alias the receipt', () => { const result = replaceNexusFaceDancers( fixture(), universe, event, () => 0.999, ); const original = reload(result.receipt); validateNexusFaceDancerHistory(universe, reload(result.receipt)); result.state.players[0].faceDancers![0].revealed = true; [result.state.players[1].traitors[0], result.state.reserve[0]] = [ result.state.reserve[0], result.state.players[1].traitors[0], ]; validateNexusTraitorSnapshot(result.state, universe); validateNexusFaceDancerHistory(universe, result.receipt); assert.deepEqual(result.receipt, original); }); void test('changed whole-batch history, source statuses, original draw order and settled reserve are detected', () => { const receipt = replaceNexusFaceDancers( fixture(), universe, event, () => 0.999, ).receipt; for (const mutate of [ (r: NexusFaceDancerReceipt) => { r.replaced.pop(); }, (r: NexusFaceDancerReceipt) => { r.replaced.reverse(); }, (r: NexusFaceDancerReceipt) => { r.drawn.reverse(); }, (r: NexusFaceDancerReceipt) => { r.source.players[0].faceDancers![0].revealed = false; }, (r: NexusFaceDancerReceipt) => { r.source.reserve.reverse(); }, (r: NexusFaceDancerReceipt) => { r.settledReserve.reverse(); }, (r: NexusFaceDancerReceipt) => { r.event = 'other'; }, (r: NexusFaceDancerReceipt) => { r.turn++; }, (r: NexusFaceDancerReceipt) => { r.phase++; }, (r: NexusFaceDancerReceipt) => { delete (r as Partial).source; }, (r: NexusFaceDancerReceipt) => { Object.assign(r, { selected: [] }); }, ]) { const corrupt = reload(receipt); mutate(corrupt); const before = reload(corrupt); assert.throws(() => validateNexusFaceDancerHistory(universe, corrupt)); assert.deepEqual(corrupt, before); } }); void test('permanently set-aside cards are never drawn, returned or resurrected as Face Dancer replacements', () => { const state = fixture(); const setAside = state.reserve.shift()!; state.removed = [setAside]; const before = reload(state); const result = replaceNexusFaceDancers(state, universe, event, () => 0.999); assert.deepEqual(state, before); assert.equal(result.receipt.drawn.includes(setAside), false); assert.equal(result.state.reserve.includes(setAside), false); assert.equal( result.state.players[0].faceDancers!.some((c) => c.leader === setAside), false, ); assert.deepEqual(result.state.removed, [setAside]); validateNexusTraitorSnapshot(result.state, universe); validateNexusFaceDancerHistory(universe, reload(result.receipt)); }); void test('replacement history validates its recorded permanent zone and rejects a rewritten one', () => { const state = fixture(); state.removed = [state.reserve.shift()!]; const receipt = replaceNexusFaceDancers(state, universe, event, () => 0.999).receipt; validateNexusFaceDancerHistory(universe, reload(receipt)); for (const mutate of [ (r: NexusFaceDancerReceipt) => { r.source.removed = []; }, (r: NexusFaceDancerReceipt) => { r.source.removed!.push(r.drawn[0]); }, (r: NexusFaceDancerReceipt) => { delete (r.source as Partial).removed; }, (r: NexusFaceDancerReceipt) => { r.replaced = [...r.replaced, r.source.removed![0]]; }, ]) { const corrupt = reload(receipt); mutate(corrupt); const before = reload(corrupt); assert.throws(() => validateNexusFaceDancerHistory(universe, corrupt)); assert.deepEqual(corrupt, before); } }); void test('a permanent zone that duplicates reserve or held Face Dancers or omits physical cards is rejected', () => { for (const mutate of [ (s: NexusTraitorSnapshot) => { s.removed = [s.reserve[0]]; }, (s: NexusTraitorSnapshot) => { s.removed = [s.players[0].faceDancers![0].leader]; }, (s: NexusTraitorSnapshot) => { s.reserve.pop(); s.removed = ['foreign-identity']; }, (s: NexusTraitorSnapshot) => { s.removed = [s.reserve.pop()!]; s.players[1].traitors.pop(); }, ]) { const state = fixture(); mutate(state); const before = reload(state); assert.throws(() => validateNexusTraitorSnapshot(state, universe)); assert.throws(() => replaceNexusFaceDancers(state, universe, event, () => { throw Error('Corrupt census must not shuffle'); }), ); assert.deepEqual(state, before); } }); void test('corrupt physical census and invalid event metadata reject without invoking shuffle', () => { for (const mutate of [ (s: NexusTraitorSnapshot) => { s.reserve[0] = s.players[0].faceDancers![0].leader; }, (s: NexusTraitorSnapshot) => { s.players[0].traitors = [universe[0]]; }, (s: NexusTraitorSnapshot) => { s.players[0].faceDancers!.pop(); }, (s: NexusTraitorSnapshot) => { s.players[1].id = '__proto__'; }, ]) { const state = fixture(); mutate(state); assert.throws(() => replaceNexusFaceDancers(state, universe, event, () => { throw Error('Corrupt census must not shuffle'); }), ); } for (const change of [ { event: '' }, { turn: 0 }, { turn: Infinity }, { phase: -1 }, { phase: 9 }, { phase: 1.5 }, ]) assert.throws(() => replaceNexusFaceDancers( fixture(), universe, { ...event, ...change }, () => { throw Error('Invalid event must not shuffle'); }, ), ); });