306 lines
9.9 KiB
TypeScript
306 lines
9.9 KiB
TypeScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
NEXUS_FACTIONS,
|
||
createNexusCards,
|
||
validateNexusCards,
|
||
drawNexusCard,
|
||
replaceNexusCard,
|
||
discardNexusCard,
|
||
nexusCardMode,
|
||
projectNexusCards,
|
||
type NexusState,
|
||
type NexusPlayer,
|
||
} from '../game/nexus-cards';
|
||
import type { FactionId } from '../game/catalog';
|
||
|
||
const players: NexusPlayer[] = [
|
||
{ id: 'a', faction: 'atreides', ally: null },
|
||
{ id: 'g', faction: 'guild', ally: null },
|
||
];
|
||
const ordered = () => createNexusCards(players, () => 0.999);
|
||
function census(state: NexusState) {
|
||
assert.deepEqual(
|
||
[
|
||
...state.deck,
|
||
...state.discard,
|
||
...Object.values(state.hands).filter(
|
||
(card): card is FactionId => card !== null,
|
||
),
|
||
].sort(),
|
||
[...NEXUS_FACTIONS].sort(),
|
||
);
|
||
validateNexusCards(JSON.parse(JSON.stringify(state)) as NexusState, players);
|
||
}
|
||
|
||
void test('every setup contains all twelve physical faction cards independent of the two through six seated factions', () => {
|
||
assert.equal(NEXUS_FACTIONS.length, 12);
|
||
for (const count of [2, 3, 6]) {
|
||
const seats = NEXUS_FACTIONS.slice(0, count).map((faction, i) => ({
|
||
id: `seat-${i}`,
|
||
faction,
|
||
}));
|
||
let calls = 0;
|
||
const state = createNexusCards(seats, () => {
|
||
calls++;
|
||
return 0.999;
|
||
});
|
||
assert.equal(calls, 11);
|
||
assert.deepEqual(state.deck, NEXUS_FACTIONS);
|
||
assert.deepEqual(state.discard, []);
|
||
assert.deepEqual(
|
||
Object.values(state.hands),
|
||
Array.from({ length: count }, () => null),
|
||
);
|
||
validateNexusCards(state, seats);
|
||
}
|
||
assert.notDeepEqual(createNexusCards(players, () => 0).deck, ordered().deck);
|
||
assert.deepEqual(
|
||
createNexusCards(players, () => 0),
|
||
createNexusCards(players, () => 0),
|
||
);
|
||
});
|
||
|
||
void test('one secret card is drawn from the top without extra randomness; own-faction draw remains optional to replace', () => {
|
||
const initial = ordered();
|
||
const before = JSON.stringify(initial);
|
||
const state = drawNexusCard(initial, 'a', players, () => {
|
||
throw new Error('A nonempty deck is not shuffled');
|
||
});
|
||
assert.equal(state.hands.a, 'atreides');
|
||
assert.equal(
|
||
nexusCardMode(state.hands.a!, 'atreides', ['atreides', 'guild']),
|
||
'cunning',
|
||
);
|
||
assert.equal(state.deck.length, 11);
|
||
assert.equal(state.discard.length, 0);
|
||
assert.equal(JSON.stringify(initial), before);
|
||
assert.throws(() => drawNexusCard(state, 'a', players, () => 0));
|
||
assert.throws(() => replaceNexusCard(initial, 'a', players, () => 0));
|
||
census(state);
|
||
});
|
||
|
||
void test('replacement discards before drawing and can draw the same physical card from a recycled discard pile', () => {
|
||
const initial = drawNexusCard(ordered(), 'a', players, () => 0);
|
||
const replaced = replaceNexusCard(initial, 'a', players, () => {
|
||
throw new Error('Still a nonempty deck');
|
||
});
|
||
assert.equal(replaced.hands.a, 'harkonnen');
|
||
assert.deepEqual(replaced.discard, ['atreides']);
|
||
census(replaced);
|
||
const exhausted = { ...initial, deck: [], discard: [...initial.deck] };
|
||
census(exhausted);
|
||
let calls = 0;
|
||
// The just-discarded own card is the final element; the first Fisher-Yates
|
||
// swap moves it to the top and remaining no-op swaps leave it there.
|
||
const redrawn = replaceNexusCard(exhausted, 'a', players, () =>
|
||
calls++ === 0 ? 0 : 0.999,
|
||
);
|
||
assert.equal(calls, 11);
|
||
assert.equal(redrawn.hands.a, 'atreides');
|
||
assert.equal(redrawn.discard.length, 0);
|
||
assert.equal(redrawn.deck.length, 11);
|
||
assert.deepEqual(exhausted.hands, initial.hands);
|
||
census(redrawn);
|
||
});
|
||
|
||
void test('using or forfeiting a held card routes it to discard once; allied players cannot draw or replace', () => {
|
||
const held = drawNexusCard(ordered(), 'a', players, () => 0);
|
||
const allied: NexusPlayer[] = [
|
||
{ ...players[0], ally: 'g' },
|
||
{ ...players[1], ally: 'a' },
|
||
];
|
||
let calls = 0;
|
||
assert.throws(() =>
|
||
drawNexusCard(ordered(), 'g', allied, () => {
|
||
calls++;
|
||
return 0;
|
||
}),
|
||
);
|
||
assert.throws(() =>
|
||
replaceNexusCard(held, 'a', allied, () => {
|
||
calls++;
|
||
return 0;
|
||
}),
|
||
);
|
||
assert.equal(calls, 0);
|
||
const forfeited = discardNexusCard(held, 'a', allied);
|
||
assert.equal(forfeited.hands.a, null);
|
||
assert.deepEqual(forfeited.discard, ['atreides']);
|
||
assert.throws(() => discardNexusCard(forfeited, 'a', allied));
|
||
assert.deepEqual(discardNexusCard(held, 'a', players), forfeited);
|
||
census(forfeited);
|
||
});
|
||
|
||
void test('an empty deck recycles only discards, keeping another held identity outside the shuffle', () => {
|
||
const held = drawNexusCard(ordered(), 'g', players, () => 0);
|
||
const state = { ...held, deck: [], discard: [...held.deck] };
|
||
let calls = 0;
|
||
const drawn = drawNexusCard(state, 'a', players, () => {
|
||
calls++;
|
||
return 0.999;
|
||
});
|
||
assert.equal(calls, 10);
|
||
assert.equal(drawn.hands.g, 'atreides');
|
||
assert.equal(drawn.hands.a, 'harkonnen');
|
||
assert.equal(drawn.discard.length, 0);
|
||
assert.equal(drawn.deck.length, 10);
|
||
census(drawn);
|
||
});
|
||
|
||
void test('all physical cards use Cunning for their faction, Betrayal for another seated faction and Secret Ally otherwise', () => {
|
||
for (const card of NEXUS_FACTIONS) {
|
||
const other = NEXUS_FACTIONS.find((faction) => faction !== card)!;
|
||
assert.equal(nexusCardMode(card, card, [card, other]), 'cunning');
|
||
assert.equal(nexusCardMode(card, other, [card, other]), 'betrayal');
|
||
assert.equal(nexusCardMode(card, other, [other]), 'secretAlly');
|
||
}
|
||
for (const [card, own, seated] of [
|
||
['unknown', 'atreides', ['atreides']],
|
||
['guild', 'atreides', ['guild']],
|
||
['guild', 'atreides', ['atreides', 'atreides']],
|
||
['guild', 'unknown', ['unknown']],
|
||
] as const)
|
||
assert.throws(() =>
|
||
nexusCardMode(
|
||
card as FactionId,
|
||
own as FactionId,
|
||
seated as readonly FactionId[],
|
||
),
|
||
);
|
||
});
|
||
|
||
void test('malformed physical census, hand capacity, inherited dictionaries and changed seat keys reject unchanged', () => {
|
||
const mutations: ((s: NexusState) => void)[] = [
|
||
(s) => {
|
||
s.deck.pop();
|
||
},
|
||
(s) => {
|
||
s.discard.push(s.deck[0]);
|
||
},
|
||
(s) => {
|
||
s.hands.a = s.deck[0];
|
||
},
|
||
(s) => {
|
||
s.deck[0] = 'unknown' as FactionId;
|
||
},
|
||
(s) => {
|
||
delete s.hands.g;
|
||
},
|
||
(s) => {
|
||
s.hands.absent = null;
|
||
},
|
||
(s) => {
|
||
s.version = 2 as 1;
|
||
},
|
||
(s) => {
|
||
s.hands.a = [] as unknown as FactionId;
|
||
},
|
||
(s) => {
|
||
s.hands = Object.create({ a: null, g: null }) as NexusState['hands'];
|
||
},
|
||
(s) => {
|
||
Object.assign(s, { privateCard: 'guild' });
|
||
},
|
||
];
|
||
for (const mutate of mutations) {
|
||
const state = ordered();
|
||
mutate(state);
|
||
const before = JSON.stringify(state);
|
||
let calls = 0;
|
||
assert.throws(() => validateNexusCards(state, players));
|
||
assert.throws(() =>
|
||
drawNexusCard(state, 'a', players, () => {
|
||
calls++;
|
||
return 0;
|
||
}),
|
||
);
|
||
assert.equal(calls, 0);
|
||
assert.equal(JSON.stringify(state), before);
|
||
}
|
||
});
|
||
|
||
void test('unsafe or duplicate player identities reject before randomness and cannot pollute hand dictionaries', () => {
|
||
for (const seats of [
|
||
[],
|
||
[players[0]],
|
||
[...players, players[0]],
|
||
[players[0], { ...players[1], faction: 'atreides' as const }],
|
||
[players[0], { ...players[1], ally: 'missing' }],
|
||
...['__proto__', 'constructor', 'prototype', ''].map((id) => [
|
||
{ ...players[0], id },
|
||
players[1],
|
||
]),
|
||
]) {
|
||
let calls = 0;
|
||
assert.throws(() =>
|
||
createNexusCards(seats, () => {
|
||
calls++;
|
||
return 0;
|
||
}),
|
||
);
|
||
assert.equal(calls, 0);
|
||
}
|
||
const state = ordered();
|
||
assert.throws(() => drawNexusCard(state, '__proto__', players, () => 0));
|
||
assert.equal(Object.getPrototypeOf(state.hands), Object.prototype);
|
||
assert.equal(Object.hasOwn(Object.prototype, 'atreides'), false);
|
||
});
|
||
|
||
void test('invalid random draws fail on initialization or recycling without altering a committed card or consuming custody', () => {
|
||
const held = drawNexusCard(ordered(), 'a', players, () => 0);
|
||
const exhausted = { ...held, deck: [], discard: [...held.deck] };
|
||
const before = JSON.stringify(exhausted);
|
||
for (const value of [-0.1, 1, NaN, Infinity, -Infinity]) {
|
||
assert.throws(() => createNexusCards(players, () => value), /random draws/);
|
||
assert.throws(
|
||
() => replaceNexusCard(exhausted, 'a', players, () => value),
|
||
/random draws/,
|
||
);
|
||
assert.equal(JSON.stringify(exhausted), before);
|
||
}
|
||
});
|
||
|
||
void test('private projection reveals only the caller’s card and public counts, and frozen inputs preserve independent JSON custody', () => {
|
||
let state = drawNexusCard(ordered(), 'a', players, () => 0);
|
||
state = drawNexusCard(state, 'g', players, () => 0);
|
||
const privateView = projectNexusCards(state, 'a', players);
|
||
assert.deepEqual(privateView, {
|
||
card: 'atreides',
|
||
deckCount: 10,
|
||
discardCount: 0,
|
||
held: { a: true, g: true },
|
||
});
|
||
const alternate = structuredClone(state);
|
||
[alternate.hands.g, alternate.deck[0]] = [
|
||
alternate.deck[0],
|
||
alternate.hands.g!,
|
||
];
|
||
alternate.deck.reverse();
|
||
assert.deepEqual(projectNexusCards(alternate, 'a', players), privateView);
|
||
assert.notDeepEqual(
|
||
projectNexusCards(alternate, 'g', players),
|
||
projectNexusCards(state, 'g', players),
|
||
);
|
||
assert.throws(() => projectNexusCards(state, 'observer-not-seated', players));
|
||
for (const player of players)
|
||
for (const field of ['hand', 'spice', 'traitors', 'battlePlan'])
|
||
Object.defineProperty(player, field, {
|
||
configurable: true,
|
||
get() {
|
||
throw new Error(`Private ${field}`);
|
||
},
|
||
});
|
||
Object.freeze(state.deck);
|
||
Object.freeze(state.discard);
|
||
Object.freeze(state.hands);
|
||
Object.freeze(state);
|
||
const next = replaceNexusCard(state, 'a', players, () => 0);
|
||
census(next);
|
||
assert.notEqual(next.deck, state.deck);
|
||
assert.notEqual(next.discard, state.discard);
|
||
assert.notEqual(next.hands, state.hands);
|
||
privateView.held.g = false;
|
||
assert.equal(projectNexusCards(state, 'a', players).held.g, true);
|
||
});
|