Implement Nexus card ownership, private draws and full card reference

This commit is contained in:
Quintine committed 2026-09-10 09:29:54 +10:00
1 parent 61689514a0
commit d2a1d63517
20 files changed
+2477 -5

No files matched your search

+305
View File
@@ -0,0 +1,305 @@
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);
});