Implement Nexus card ownership, private draws and full card reference
This commit is contained in:
1 parent
61689514a0
commit
d2a1d63517
20 files changed
+2477
-5
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user