Files
Dune/tests/nexus-cards.test.ts

306 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});