342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
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 = <T>(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<NexusFaceDancerReceipt>).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<NexusTraitorSnapshot>).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');
|
|
},
|
|
),
|
|
);
|
|
});
|