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

+188
View File
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeNexusGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { baseDeck, type SpiceCard } from '../game/cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
export const nexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
export const nexusPlayer = (g: Game, id: string) =>
g.players.find((p) => p.id === id)!;
/** Final seat identities precede the genuine base setup. The only module seam
* enables the unfinished Nexus module through its explicit audit initializer. */
export function nexusFixture(
options: {
seatIds?: [string, string, string];
advanced?: boolean;
hostFaction?: 'fremen' | 'moritani' | 'ecaz';
} = {},
): Game {
const [f, a, h] = options.seatIds ?? ['f', 'a', 'h'];
let g = createGame(
'NEXUSAUDIT',
newPlayer(
f,
options.hostFaction ?? 'fremen',
options.hostFaction ?? 'fremen',
),
options.advanced ?? false,
[],
);
joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen'));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g.nexusCards = { cards: null, phase: null };
g = initializeNexusGameForAudit(g);
for (let i = 0; g.status === 'setup' && i < 60; i++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
g = next;
}
assert.equal(g.status, 'playing');
assert.equal(g.homeworlds, undefined);
for (const p of g.players) g.deck.push(...p.hand.splice(0));
nexusInventory(g);
return g;
}
export function nexusReady(state: Game): Game {
let g = state;
for (const p of state.players)
if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
export function nexusAllow(state: Game): Game {
let g = state;
for (let i = 0; g.response && i < 20; i++) {
const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
g = applyAction(g, id, { type: 'passResponse' });
}
assert.equal(g.response, null);
return g;
}
/** Later turns stage only a settled Storm boundary after a completed phase.
* The tracker for the next Spice Blow is always created by actual Storm play. */
export function enterNexusSpice(state: Game, nextTurn = false): Game {
let g = nexusReload(state);
if (nextTurn) {
assert.equal(g.nexusCards?.phase?.stage, 'complete');
Object.assign(g, {
turn: g.turn + 1,
phase: 0,
ready: [],
stormPending: null,
stormDialers: g.players.slice(0, 2).map((p) => p.id),
stormDials: {},
});
}
assert.equal(g.phase, 0);
for (const id of g.stormDialers)
g = applyAction(g, id, { type: 'stormDial', amount: g.turn === 1 ? 0 : 1 });
g = nexusAllow(nexusReady(g));
assert.equal(g.phase, 1);
assert.equal(g.nexusCards?.phase?.stage, 'spice');
return g;
}
/** Reorder existing physical spice cards without adding/removing their census. */
export function orderNexusSpice(g: Game, kinds: ('land' | 'worm')[]): void {
const all = [...g.spiceDeck, ...g.spiceDiscard.flat()];
const front: SpiceCard[] = [];
for (const kind of kinds) {
const index = all.findIndex((card) =>
kind === 'land' ? 'territory' in card : 'worm' in card,
);
assert.ok(index >= 0);
front.push(all.splice(index, 1)[0]);
}
g.spiceDeck = [...front, ...all];
g.spiceDiscard = [[], []];
}
export function finishNexusSpice(state: Game): Game {
let g = state;
for (
let i = 0;
g.phase === 1 && g.nexusCards?.phase?.stage === 'spice' && i < 30;
i++
) {
if (g.response) g = nexusAllow(g);
else if (g.decision?.kind === 'wormRide')
g = applyAction(g, g.decision.player, {
type: 'decision',
accept: false,
});
else {
assert.equal(g.decision, null);
g = nexusReady(g);
}
}
assert.notEqual(g.nexusCards?.phase?.stage, 'spice');
return g;
}
export function nexusTurnTwo(
options: {
seatIds?: [string, string, string];
advanced?: boolean;
hostFaction?: 'fremen' | 'moritani' | 'ecaz';
} = {},
): Game {
const first = enterNexusSpice(nexusFixture(options));
orderNexusSpice(first, ['land', 'land']);
return enterNexusSpice(finishNexusSpice(first), true);
}
export function nexusInventory(g: Game): void {
assert.ok(g.nexusCards?.cards);
validateNexusCards(g.nexusCards.cards, g.players);
assert.deepEqual(
[
...g.nexusCards.cards.deck,
...g.nexusCards.cards.discard,
...Object.values(g.nexusCards.cards.hands).filter(
(card) => card !== null,
),
].sort(),
[...NEXUS_FACTIONS].sort(),
);
assert.deepEqual(
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort(),
baseDeck()
.map((c) => c.id)
.sort(),
);
for (const p of g.players)
assert.equal(
p.reserves +
p.tanks +
Object.values(p.forces).reduce((sum, n) => sum + n, 0),
20,
);
}
export function nexusReject(
g: Game,
id: string,
action: Action,
pattern: RegExp = /Nexus/,
): void {
const before = nexusReload(g);
assert.throws(() => applyAction(g, id, action), pattern);
assert.deepEqual(g, before);
}
+158
View File
@@ -0,0 +1,158 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { FACTIONS, type FactionId } from '../game/catalog';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { NEXUS_CARD_REFERENCE, NEXUS_PANEL_NAMES, nexusCardReference } from '../game/nexus-card-reference';
// Match Vinext's actual next/image alias in this isolated Node test process;
// render the production image implementation, not a component mock.
const imageAlias = registerHooks({ resolve(specifier, context, nextResolve) {
return nextResolve(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
} });
const { NexusCardFace, NexusCardGallery, NexusCards, nexusCardChoiceAction } = await import('../components/nexus-cards');
imageAlias.deregister();
/** Only public seats and the server's private owner projection feed the UI. */
function fixture(card: FactionId | null = null): GameView {
const g = createGame('NEXUSCONTROLS', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
Object.assign(g, { status: 'playing', phase: 1, turn: 2, active: 'e' });
const view = viewGame(g, 'a');
view.nexusCards = { card, deckCount: card ? 9 : 10, discardCount: 0,
held: { a: !!card, h: true, e: true }, turn: 2,
choices: card ? ['keep', 'replace'] : ['keep', 'draw'], waiting: ['a', 'h'] };
return view;
}
function markup(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(NexusCards, { game, busy, act() {} }));
}
void test('the original inventory renders twelve identities, all36 readable panels and inspect controls', () => {
assert.equal(NEXUS_CARD_REFERENCE.length, 12);
assert.deepEqual(NEXUS_CARD_REFERENCE.map((card) => card.faction).sort(), FACTIONS.map((faction) => faction.id).sort());
const html = renderToStaticMarkup(createElement(NexusCardGallery));
for (const mode of ['betrayal', 'cunning', 'secretAlly'] as const)
assert.equal(html.split(`aria-label="${NEXUS_PANEL_NAMES[mode]}"`).length - 1, 12);
assert.equal((html.match(/aria-label="Inspect [^"]+ Nexus card"/g) ?? []).length, 12);
for (const card of NEXUS_CARD_REFERENCE) {
for (const mode of ['betrayal', 'cunning', 'secretAlly'] as const) {
assert.ok(card[mode].length > 30);
assert.ok(html.includes(card[mode]));
}
}
});
void test('reference preserves materially different physical effects and original numerical limits', () => {
assert.match(nexusCardReference('guild').betrayal, /whole shipment payment.*own payment.*Junction/);
assert.match(nexusCardReference('guild').cunning, /second shipment.*cannot move unless.*Hajr/);
assert.match(nexusCardReference('richese').cunning, /two No-Field.*one force.*reveal one/);
assert.match(nexusCardReference('tleilaxu').secretAlly, /leader.*free.*five.*one spice each/);
assert.match(nexusCardReference('emperor').secretAlly, /three extra forces free beyond/);
assert.match(nexusCardReference('ecaz').cunning, /capture.*Tanks.*Ghola.*Moritani.*end of the turn/);
assert.match(nexusCardReference('moritani').betrayal, /Return.*without revealing/);
assert.match(nexusCardReference('harkonnen').betrayal, /replacement during Mentat Pause/);
assert.match(nexusCardReference('choam').secretAlly, /not used in that battle/);
assert.match(nexusCardReference('ixians').betrayal, /cannot prevent both/);
assert.doesNotMatch(JSON.stringify(NEXUS_CARD_REFERENCE), /INTERNAL AUDIT|ALTERNATE SUPPLY CHAIN|entire Battle Plan/);
});
void test('draw, replacement and keep actions bind the exact offered turn and card without mutation', () => {
for (const card of [null, 'fremen'] as const) {
const view = fixture(card);
const before = structuredClone(view);
for (const choice of view.nexusCards!.choices)
assert.deepEqual(nexusCardChoiceAction(view, choice), { type: 'nexusCardChoice', turn: 2, card, choice, ownRedraws: 0 });
assert.equal(nexusCardChoiceAction(view, card ? 'draw' : 'replace'), null);
assert.deepEqual(view, before);
}
});
void test('private pre-draw policy applies in Basic and Advanced without a revealing post-draw prompt', () => {
for (const advanced of [false, true]) {
for (const card of [null, 'atreides', 'fremen'] as const) {
const view = fixture(card);
view.advanced = advanced;
const choice = card ? 'replace' : 'draw';
for (const ownRedraws of [0, 1, 2] as const)
assert.deepEqual(nexusCardChoiceAction(view, choice, ownRedraws), { type: 'nexusCardChoice', turn: 2, card, choice, ownRedraws });
assert.equal(nexusCardChoiceAction(view, choice, 3 as never), null);
assert.equal(nexusCardChoiceAction(view, 'keep', 2)!.ownRedraws, 0);
assert.match(markup(view), /aria-label="If I draw my faction"/);
assert.match(markup(view), /Basic or Advanced play/);
assert.match(markup(view), /Redraw whenever it appears/);
view.nexusCards!.choices = [];
view.nexusCards!.waiting = ['h'];
assert.doesNotMatch(markup(view), /If I draw my faction|You drew your own faction|Redraw my faction card/);
}
}
});
void test('settled, stale, nonplaying and nonowner opportunities cannot generate an action', () => {
for (const edit of [
(view: GameView) => { view.nexusCards!.waiting = ['h']; },
(view: GameView) => { view.nexusCards!.choices = []; },
(view: GameView) => { view.nexusCards!.turn = 1; },
(view: GameView) => { view.nexusCards!.turn = null; },
(view: GameView) => { view.phase = 2; },
(view: GameView) => { view.status = 'finished'; },
(view: GameView) => { view.me = 'observer'; },
]) {
const view = fixture();
edit(view);
assert.equal(nexusCardChoiceAction(view, 'draw'), null);
assert.equal(nexusCardChoiceAction(view, 'keep'), null);
}
});
void test('the owned face highlights its mode from the public roster and all three requirements remain readable', () => {
for (const [card, mode] of [['atreides', 'Cunning'], ['harkonnen', 'Betrayal'], ['fremen', 'Secret Ally']] as const) {
const html = markup(fixture(card));
const privateFace = html.slice(html.indexOf('aria-label="Your private Nexus card"'), html.indexOf('<details'));
assert.match(privateFace, new RegExp(`${mode}<span[^>]*>Your applicable mode`));
assert.equal((privateFace.match(/Your applicable mode/g) ?? []).length, 1);
assert.match(privateFace, /player only/);
assert.match(privateFace, /Another player controls/);
assert.match(privateFace, /is not in this game/);
}
});
void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => {
const view = fixture();
const html = markup(view, true);
assert.match(html, /10 in deck · 0 discarded · 2 held/);
assert.match(html, /Waiting for Nexus choices: Harkonnen/);
assert.match(html, /You do not hold a Nexus card/);
assert.match(html, /effects are not playable yet/);
assert.match(html, /<button[^>]*disabled=""[^>]*>Draw a Nexus card/);
assert.match(html, /<button[^>]*disabled=""[^>]*>Skip this draw/);
assert.doesNotMatch(html, />Play |Activate effect|Use Betrayal/);
const enlarged = renderToStaticMarkup(createElement(NexusCardFace, { card: 'atreides', enlarged: true }));
assert.match(enlarged, /text-xl! leading-8!/);
assert.doesNotMatch(enlarged, /<button/);
});
void test('rendering and legal choices never inspect other hands, source deck order, spice or secret battle fields', () => {
const view = fixture('fremen');
const expected = markup(view);
for (const player of view.players)
for (const field of ['hand', 'traitors', 'spice', 'prediction'])
Object.defineProperty(player, field, { get() { throw new Error(`Private ${field} inspected`); } });
for (const field of ['deck', 'discard', 'battle', 'hands', 'noField'])
Object.defineProperty(view, field, { get() { throw new Error(`Source ${field} inspected`); } });
Object.defineProperty(view.nexusCards!, 'hands', { get() { throw new Error('Opposing held identities inspected'); } });
assert.equal(markup(view), expected);
assert.ok(nexusCardChoiceAction(view, 'replace'));
assert.ok(nexusCardChoiceAction(view, 'keep'));
});
void test('disabled module reads no player or card source and renders no inventory', () => {
const view = fixture();
view.nexusCards = null;
Object.defineProperty(view, 'players', { get() { throw new Error('Unnecessary player access'); } });
assert.equal(markup(view), '');
assert.equal(nexusCardChoiceAction(view, 'draw'), null);
});
+372
View File
@@ -0,0 +1,372 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { placeAmbassador } from '../game/ecaz-ambassadors';
import { placeTerror } from '../game/moritani-terror';
import {
enterNexusSpice,
finishNexusSpice,
nexusFixture,
nexusAllow,
nexusInventory,
nexusPlayer,
nexusReady,
nexusReject,
nexusReload,
nexusTurnTwo,
orderNexusSpice,
} from './fixture-nexus-cards';
function alliance(g: Game, one = 'f', two = 'a'): Game {
g = applyAction(g, one, { type: 'alliance', target: two });
return applyAction(g, two, { type: 'alliance', target: one });
}
function negotiations(g: Game): Game {
g = nexusReady(g);
assert.ok(g.spiceWindow);
g = nexusReady(g);
assert.equal(g.nexus, true);
assert.equal(g.spiceWindow, null);
return g;
}
function choice(g: Game, id: string, value: string): Action {
return {
type: 'nexusCardChoice',
turn: g.turn,
card: g.nexusCards!.cards!.hands[id],
choice: value,
ownRedraws: 0,
};
}
function top(g: Game, ...cards: FactionId[]): void {
const deck = g.nexusCards!.cards!.deck;
assert.ok(cards.every((card) => deck.includes(card)));
g.nexusCards!.cards!.deck = [
...cards,
...deck.filter((card) => !cards.includes(card)),
];
}
function closing(advanced = false): Game {
let g = nexusTurnTwo({ advanced });
orderNexusSpice(g, ['worm', 'land', 'land']);
g = alliance(negotiations(g));
return finishNexusSpice(g);
}
for (const advanced of [false, true])
void test(`${advanced ? 'Advanced A/B' : 'Basic'} real phase draws once only after all spice passes and settled alliances`, () => {
let g = nexusTurnTwo({ advanced });
assert.equal(g.nexusCards!.cards!.deck.length, 12);
orderNexusSpice(g, ['worm', 'land', 'land']);
g = alliance(negotiations(g));
assert.equal(g.nexusCards!.phase!.occurred, true);
assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, []);
g = nexusReady(g);
if (advanced) {
assert.equal(g.spiceSequence!.pile, 1);
assert.ok(g.spiceWindow);
assert.equal(g.nexusCards!.cards!.deck.length, 12);
assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, []);
}
g = finishNexusSpice(g);
assert.equal(g.phase, 1);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
assert.equal(viewGame(g, 'h').beforeSpiceDraw, false);
top(g, 'ecaz');
const draw = choice(g, 'h', 'draw');
g = applyAction(nexusReload(g), 'h', draw);
assert.equal(g.phase, 2);
assert.equal(g.nexusCards!.phase!.stage, 'complete');
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
assert.equal(g.nexusCards!.cards!.deck.length, 11);
nexusReject(g, 'h', draw);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
nexusInventory(g);
});
void test('first-turn skipped worm, no worm, and actual Nexus without any alliance do not grant a card', () => {
let first = enterNexusSpice(nexusFixture());
orderNexusSpice(first, ['worm', 'land']);
first = finishNexusSpice(first);
assert.equal(first.nexusCards!.phase!.occurred, false);
assert.equal(first.phase, 2);
let noWorm = enterNexusSpice(first, true);
orderNexusSpice(noWorm, ['land']);
noWorm = finishNexusSpice(noWorm);
assert.deepEqual(noWorm.nexusCards!.phase!.eligible, []);
let noAlliance = enterNexusSpice(noWorm, true);
orderNexusSpice(noAlliance, ['worm', 'land']);
noAlliance = finishNexusSpice(noAlliance);
assert.equal(noAlliance.nexusCards!.phase!.occurred, true);
assert.deepEqual(noAlliance.nexusCards!.phase!.eligible, []);
assert.equal(noAlliance.nexusCards!.cards!.deck.length, 12);
nexusInventory(noAlliance);
});
void test('a real before-blow Fremen summoned Nexus resumes both Advanced piles and offers one final draw', () => {
let g = nexusTurnTwo({ advanced: true });
orderNexusSpice(g, ['land', 'land']);
const index = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
nexusPlayer(g, 'f').hand.push(card);
const beforeSpice = nexusReload(g).spiceDeck;
g = nexusAllow(
applyAction(g, 'f', {
type: 'card',
mode: 'special',
card: card.id,
territory: 'the_great_flat',
}),
);
assert.equal(g.summonedBeforeBlow, true);
assert.equal(g.nexusCards!.phase!.occurred, true);
assert.deepEqual(g.spiceDeck, beforeSpice);
assert.equal(g.discard.filter((item) => item.id === card.id).length, 1);
g = alliance(negotiations(nexusReload(g)));
g = finishNexusSpice(g);
assert.equal(
g.spiceDiscard[0].filter((item) => 'territory' in item).length,
1,
);
assert.equal(
g.spiceDiscard[1].filter((item) => 'territory' in item).length,
1,
);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
top(g, 'ecaz');
g = applyAction(nexusReload(g), 'h', choice(g, 'h', 'draw'));
assert.equal(g.phase, 2);
assert.equal(g.nexusCards!.cards!.deck.length, 11);
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
nexusInventory(g);
});
void test('precommitted own-faction policy completes privately and later replacement discards once', () => {
const initial = closing();
top(initial, 'harkonnen', 'ecaz', 'moritani');
const keptOwn = applyAction(initial, 'h', choice(initial, 'h', 'draw'));
assert.equal(keptOwn.phase, 2);
assert.equal(keptOwn.nexusCards!.cards!.hands.h, 'harkonnen');
assert.deepEqual(viewGame(keptOwn, 'a').nexusCards!.waiting, []);
let g = applyAction(nexusReload(initial), 'h', {
...choice(initial, 'h', 'draw'),
ownRedraws: 1,
});
assert.equal(g.phase, 2);
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
assert.deepEqual(g.nexusCards!.cards!.discard, ['harkonnen']);
assert.deepEqual(viewGame(g, 'a').nexusCards!.waiting, []);
nexusReject(g, 'h', choice(initial, 'h', 'draw'));
const repeatedPolicy = applyAction(initial, 'h', {
...choice(initial, 'h', 'draw'),
ownRedraws: 2,
});
assert.deepEqual(repeatedPolicy.nexusCards, g.nexusCards);
g = enterNexusSpice(g, true);
orderNexusSpice(g, ['worm', 'land']);
g = finishNexusSpice(g);
assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, ['keep', 'replace']);
const replace = choice(g, 'h', 'replace');
g = applyAction(nexusReload(g), 'h', replace);
assert.equal(g.nexusCards!.cards!.hands.h, 'moritani');
assert.deepEqual(g.nexusCards!.cards!.discard, ['harkonnen', 'ecaz']);
nexusReject(g, 'h', replace);
nexusInventory(g);
});
void test('closing choices fence stale owners/cards/turns and every other action; all four bots emit a legal choice', () => {
const g = closing();
for (const action of [
{ type: 'ready' },
{ type: 'alliance', target: 'f' },
{ type: 'card', card: 'invented' },
{ type: 'ship', amount: 1 },
{ type: 'decision', decline: true },
{ ...choice(g, 'h', 'draw'), turn: g.turn - 1 },
{ ...choice(g, 'h', 'draw'), card: 'atreides' },
{ ...choice(g, 'h', 'draw'), injected: true },
])
nexusReject(g, 'h', action);
nexusReject(g, 'f', choice(g, 'f', 'draw'));
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'h');
view.players.find((p) => p.id === 'h')!.bot = difficulty;
const action = botActions(view)[0];
assert.equal(action?.type, 'nexusCardChoice');
assert.doesNotThrow(() => applyAction(g, 'h', action!));
}
const kept = applyAction(g, 'h', choice(g, 'h', 'keep'));
assert.equal(kept.phase, 2);
assert.equal(kept.nexusCards!.cards!.deck.length, 12);
});
void test('private card projection exposes only own identity; a real reciprocal Nexus join forfeits held card exactly once', () => {
let g = closing();
top(g, 'ecaz');
g = applyAction(g, 'h', choice(g, 'h', 'draw'));
for (const id of ['f', 'a', 'h']) {
const projected = viewGame(nexusReload(g), id).nexusCards!;
assert.equal(projected.card, id === 'h' ? 'ecaz' : null);
assert.deepEqual(projected.held, { f: false, a: false, h: true });
assert.deepEqual(Object.keys(projected).sort(), [
'card',
'choices',
'deckCount',
'discardCount',
'held',
'turn',
'waiting',
]);
if (id !== 'h')
assert.equal(JSON.stringify(projected).includes('ecaz'), false);
}
assert.equal(JSON.stringify(g.log).includes('ecaz'), false);
g = enterNexusSpice(g, true);
orderNexusSpice(g, ['worm', 'land']);
g = negotiations(g);
g = applyAction(g, 'a', { type: 'alliance' });
g = applyAction(g, 'h', { type: 'alliance', target: 'a' });
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz', 'offer is not acceptance');
g = applyAction(nexusReload(g), 'a', { type: 'alliance', target: 'h' });
assert.equal(g.nexusCards!.cards!.hands.h, null);
assert.deepEqual(g.nexusCards!.cards!.discard, ['ecaz']);
assert.equal(nexusPlayer(g, 'h').ally, 'a');
nexusInventory(g);
});
void test('malformed inventory and closing state reject all views, normalization and action without repair', () => {
const g = closing();
const corruptions: ((g: Game) => void)[] = [
(x) => {
x.nexusCards!.cards!.deck.pop();
},
(x) => {
x.nexusCards!.cards!.hands.h = x.nexusCards!.cards!.deck[0];
},
(x) => {
x.nexusCards!.phase!.done = ['f'];
},
(x) => {
x.nexusCards!.phase!.occurred = false;
},
(x) => {
x.nexusCards!.phase!.turn--;
},
(x) => {
x.nexusCards!.phase = null;
},
];
for (const corrupt of corruptions) {
const bad = nexusReload(g);
corrupt(bad);
const before = nexusReload(bad);
for (const p of bad.players)
assert.throws(() => viewGame(bad, p.id), /Nexus/);
assert.throws(() => normalizeAutomaticGame(bad), /Nexus/);
assert.throws(() => applyAction(bad, 'h', choice(g, 'h', 'draw')), /Nexus/);
assert.deepEqual(bad, before);
}
});
for (const hostFaction of ['moritani', 'ecaz'] as const)
void test(`${hostFaction} actual accepted entry alliance forfeits a genuinely drawn card, but declaration and refusal retain it`, () => {
// This explicit expansion roster enters the same genuine audit setup before
// any Nexus custody exists. Base Treachery inventory remains unchanged.
let g = nexusTurnTwo({ hostFaction });
orderNexusSpice(g, ['worm', 'land']);
g = alliance(negotiations(g), 'a', 'h');
g = finishNexusSpice(g);
top(g, 'richese');
g = applyAction(g, 'f', choice(g, 'f', 'draw'));
// Stage a later unused shipment, retaining the completed real phase receipt.
// The entrant first ends its old alliance in the next genuine Nexus.
g = enterNexusSpice(g, true);
orderNexusSpice(g, ['worm', 'land']);
g = negotiations(g);
g = applyAction(g, 'a', { type: 'alliance' });
g = finishNexusSpice(g);
Object.assign(g, {
phase: 5,
active: 'a',
order: ['a', 'f', 'h'],
movementRemaining: ['a', 'f', 'h'],
ready: [],
storm: 18,
});
for (const p of g.players) {
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0);
p.forces = {};
Object.assign(p, { spice: 20, shipped: false, moved: 0 });
}
if (hostFaction === 'moritani') {
const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
g.moritaniTerror = placeTerror(
g.moritaniTerror!,
token.id,
'arrakeen',
g.turn,
);
} else {
const token = g.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!;
g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors!, token.id, {
turn: g.turn,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
}
g = applyAction(g, 'a', {
type: 'ship',
amount: 2,
territory: 'arrakeen',
sector: 10,
});
const declare: Action =
hostFaction === 'moritani'
? { type: 'decision', alliance: true }
: {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
choice: 'alliance',
beneficiary: 'f',
};
g = applyAction(g, 'f', declare);
assert.equal(g.nexusCards!.cards!.hands.f, 'richese');
assert.equal(
g.response,
null,
'empty Treachery hands need no Karama response',
);
const reply: Action =
hostFaction === 'moritani'
? { type: 'decision', accept: true }
: { type: 'decision', event: g.pendingAmbassador!.event, accept: true };
const refused = applyAction(nexusReload(g), 'a', {
...reply,
accept: false,
});
assert.equal(refused.nexusCards!.cards!.hands.f, 'richese');
const accepted = applyAction(nexusReload(g), 'a', reply);
assert.equal(accepted.nexusCards!.cards!.hands.f, null);
assert.deepEqual(accepted.nexusCards!.cards!.discard, ['richese']);
assert.equal(nexusPlayer(accepted, 'f').ally, 'a');
assert.equal(nexusPlayer(accepted, 'a').shipped, true);
assert.equal(accepted.pendingTerrorEntry ?? null, null);
assert.equal(accepted.pendingAmbassador ?? null, null);
nexusInventory(accepted);
});
+238
View File
@@ -0,0 +1,238 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createNexusCardPhase,
markNexusCardOccurred,
closeNexusCardPhase,
finishNexusCardChoice,
nexusCardChoices,
validateNexusCardPhase,
type NexusCardPhase,
type NexusCardChoice,
} from '../game/nexus-card-phase';
import {
createNexusCards,
drawNexusCard,
replaceNexusCard,
projectNexusCards,
validateNexusPlayers,
type NexusPlayer,
type NexusState,
} from '../game/nexus-cards';
const players: NexusPlayer[] = [
{ id: 'a', faction: 'atreides', ally: null },
{ id: 'g', faction: 'guild', ally: 'h' },
{ id: 'h', faction: 'harkonnen', ally: 'g' },
{ id: 'e', faction: 'emperor', ally: null },
];
const cards = () => createNexusCards(players, () => 0.999);
const open = () =>
closeNexusCardPhase(markNexusCardOccurred(createNexusCardPhase(3)), players);
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
void test('only an actual Nexus and a settled alliance open the closing opportunity', () => {
const initial = createNexusCardPhase(3);
assert.deepEqual(nexusCardChoices(initial, cards(), 'a'), []);
assert.equal(closeNexusCardPhase(initial, players).stage, 'complete');
const unallied = players.map((p) => ({ ...p, ally: null }));
assert.equal(
closeNexusCardPhase(markNexusCardOccurred(initial), unallied).stage,
'complete',
);
const marked = markNexusCardOccurred(initial);
assert.equal(initial.occurred, false);
assert.deepEqual(markNexusCardOccurred(marked), marked);
const phase = closeNexusCardPhase(marked, players);
assert.deepEqual(phase.eligible, ['a', 'e']);
assert.equal(phase.stage, 'drawing');
assert.deepEqual(nexusCardChoices(phase, cards(), 'g'), []);
assert.throws(() => closeNexusCardPhase(phase, players));
assert.throws(() => markNexusCardOccurred(phase));
});
void test('each eligible owner may finish once in either order and the whole phase closes once', () => {
const initial = open();
const state = cards();
const first = finishNexusCardChoice(initial, players, state, 'e', 'keep');
assert.deepEqual(first.done, ['e']);
assert.equal(first.stage, 'drawing');
assert.deepEqual(nexusCardChoices(first, state, 'e'), []);
const drawn = drawNexusCard(state, 'a', players, () => 0);
const complete = finishNexusCardChoice(
reload(first),
players,
reload(drawn),
'a',
'draw',
);
assert.equal(complete.stage, 'complete');
assert.deepEqual(initial.done, []);
validateNexusCardPhase(reload(complete), players, drawn);
assert.throws(() =>
finishNexusCardChoice(complete, players, drawn, 'a', 'keep'),
);
assert.throws(() => closeNexusCardPhase(complete, players));
assert.throws(() => markNexusCardOccurred(complete));
});
void test('keeping empty or held hands is legal; replacement requires the physical transaction first', () => {
const initial = open();
const state = cards();
assert.deepEqual(nexusCardChoices(initial, state, 'a'), ['keep', 'draw']);
assert.throws(() =>
finishNexusCardChoice(initial, players, state, 'a', 'draw'),
);
const held = drawNexusCard(state, 'a', players, () => 0);
assert.deepEqual(nexusCardChoices(initial, held, 'a'), ['keep', 'replace']);
const replaced = replaceNexusCard(held, 'a', players, () => 0);
const done = finishNexusCardChoice(
initial,
players,
replaced,
'a',
'replace',
);
assert.deepEqual(done.done, ['a']);
assert.equal(held.hands.a, 'atreides');
assert.equal(replaced.hands.a, 'harkonnen');
assert.deepEqual(replaced.discard, ['atreides']);
assert.deepEqual(
finishNexusCardChoice(initial, players, held, 'a', 'keep').done,
['a'],
);
});
void test('own-faction and other draws have identical public progress and observer projections', () => {
const phase = open();
const ownDeck = cards();
const otherDeck = reload(ownDeck);
[otherDeck.deck[0], otherDeck.deck[1]] = [
otherDeck.deck[1],
otherDeck.deck[0],
];
const own = drawNexusCard(ownDeck, 'a', players, () => 0);
const other = drawNexusCard(otherDeck, 'a', players, () => 0);
assert.notEqual(own.hands.a, other.hands.a);
const ownDone = finishNexusCardChoice(phase, players, own, 'a', 'draw');
const otherDone = finishNexusCardChoice(phase, players, other, 'a', 'draw');
assert.deepEqual(ownDone, otherDone);
assert.deepEqual(
projectNexusCards(own, 'e', players),
projectNexusCards(other, 'e', players),
);
assert.deepEqual(nexusCardChoices(ownDone, own, 'a'), []);
});
void test('a recycled own card may repeat without creating a second phase opportunity', () => {
const phase = open();
const held = drawNexusCard(cards(), 'a', players, () => 0);
const exhausted: NexusState = { ...held, deck: [], discard: [...held.deck] };
let calls = 0;
const same = replaceNexusCard(exhausted, 'a', players, () =>
calls++ === 0 ? 0 : 0.999,
);
assert.equal(same.hands.a, 'atreides');
const different = replaceNexusCard(same, 'a', players, () => {
throw new Error('No shuffle needed');
});
assert.notEqual(different.hands.a, 'atreides');
const done = finishNexusCardChoice(phase, players, different, 'a', 'replace');
assert.deepEqual(done.done, ['a']);
assert.throws(() =>
finishNexusCardChoice(done, players, different, 'a', 'replace'),
);
});
void test('saved progress detects rewinds, altered recipients and unknown fields without mutation', () => {
const state = cards();
let complete = finishNexusCardChoice(open(), players, state, 'a', 'keep');
complete = finishNexusCardChoice(complete, players, state, 'e', 'keep');
const mutations: Array<(phase: NexusCardPhase) => void> = [
(p) => {
p.stage = 'drawing';
p.done = [];
},
(p) => {
p.turn++;
},
(p) => {
p.occurred = false;
},
(p) => {
p.eligible.reverse();
},
(p) => {
p.done.reverse();
},
(p) => {
p.eligible.push('outsider');
},
(p) => {
p.signature = '';
},
(p) => {
Object.assign(p, { redraw: 'a' });
},
];
for (const mutate of mutations) {
const corrupt = reload(complete);
mutate(corrupt);
const before = JSON.stringify(corrupt);
assert.throws(() => validateNexusCardPhase(corrupt, players, state));
assert.throws(() => nexusCardChoices(corrupt, state, 'a'));
assert.throws(() =>
finishNexusCardChoice(corrupt, players, state, 'a', 'keep'),
);
assert.equal(JSON.stringify(corrupt), before);
}
});
void test('unknown choices, premature finishes and foreign owners reject unchanged', () => {
const phase = open();
const state = cards();
const before = JSON.stringify({ phase, state });
for (const choice of ['redraw', 'skip', '', null, 1])
assert.throws(() =>
finishNexusCardChoice(
phase,
players,
state,
'a',
choice as NexusCardChoice,
),
);
for (const owner of ['g', 'outsider'])
assert.throws(() =>
finishNexusCardChoice(phase, players, state, owner, 'keep'),
);
assert.throws(() =>
finishNexusCardChoice(createNexusCardPhase(3), players, state, 'a', 'keep'),
);
assert.equal(JSON.stringify({ phase, state }), before);
});
void test('one-way, missing and overlapping alliance partners cannot qualify a Nexus window', () => {
const state = cards();
for (const seats of [
players.map((p) => (p.id === 'h' ? { ...p, ally: null } : p)),
players.map((p) => (p.id === 'g' ? { ...p, ally: 'outsider' } : p)),
players.map((p) => (p.id === 'a' ? { ...p, ally: 'g' } : p)),
]) {
const before = JSON.stringify(seats);
assert.throws(() => validateNexusPlayers(seats));
assert.throws(() =>
closeNexusCardPhase(
markNexusCardOccurred(createNexusCardPhase(3)),
seats,
),
);
assert.throws(() => validateNexusCardPhase(open(), seats, state));
assert.equal(JSON.stringify(seats), before);
}
const changed = players.map((p) => ({ ...p, ally: null }));
assert.throws(() => validateNexusCardPhase(open(), changed, state));
let complete = finishNexusCardChoice(open(), players, state, 'a', 'keep');
complete = finishNexusCardChoice(complete, players, state, 'e', 'keep');
validateNexusCardPhase(complete, changed, state);
});
+203
View File
@@ -0,0 +1,203 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash, webcrypto } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import type * as Rooms from '../db/rooms';
import { nexusTurnTwo, nexusReady, orderNexusSpice, finishNexusSpice, nexusInventory } from './fixture-nexus-cards';
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
function unitStore(runBots = bots.runBots) {
const sqlite = new DatabaseSync(':memory:');
const hooks: { beforeWrite?: () => Promise<void> } = {};
const writes: { expected: number; changes: number }[] = [];
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((f) => f.endsWith('.sql'))
.sort())
sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
class Statement {
values: (string | number | null)[] = [];
constructor(readonly sql: string) {}
bind(...values: (string | number | null)[]) {
this.values = values;
return this;
}
async first() {
return sqlite.prepare(this.sql).get(...this.values) ?? null;
}
async run() {
const continuation = this.sql.startsWith('UPDATE rooms SET state');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
);
if (continuation)
writes.push({ expected: Number(this.values[3]), changes });
return { meta: { changes } };
}
}
const database = {
prepare: (sql: string) => new Statement(sql),
batch: async (statements: Statement[]) => {
sqlite.exec('BEGIN');
try {
const results = [];
for (const statement of statements) results.push(await statement.run());
sqlite.exec('COMMIT');
return results;
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
function loadRooms() {
const exports = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports,
crypto: webcrypto,
TextEncoder,
structuredClone,
// Keep persisted JSON values in the test realm for strict structural assertions.
JSON,
require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine') return engine;
if (name === '@/game/bots') return { ...bots, runBots };
throw new Error('Unexpected module ' + name);
},
},
);
return exports as typeof Rooms;
}
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
}
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
async function persisted() {
const store = unitStore();
const made = await store.rooms.createRoom('Nexus SQL QA', 'fremen', false, []);
const code = made.view.code;
const credentials = [made.token];
for (const faction of ['atreides', 'harkonnen'] as const)
credentials.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(credentials.map(token => store.rooms.authenticate(code, token)));
const ids = auths.map(auth => auth.playerId) as [string,string,string];
let initial = nexusTurnTwo({seatIds:ids});
orderNexusSpice(initial, ['worm','land']);
initial = nexusReady(nexusReady(initial));
initial = engine.applyAction(initial, ids[0], {type:'alliance',target:ids[1]});
initial = engine.applyAction(initial, ids[1], {type:'alliance',target:ids[0]});
initial = finishNexusSpice(initial);
initial.code = code;
initial.version = (await store.rooms.readRoom(code)).version;
const cards = initial.nexusCards!.cards!;
cards.deck = ['harkonnen','ecaz',...cards.deck.filter(card => !['harkonnen','ecaz'].includes(card))];
const save = (state: engine.Game) => store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(state),state.version,code);
save(initial);
const other = await store.rooms.createRoom('Untouched Nexus room','fremen',false,[]);
const otherBefore = await store.rooms.readRoom(other.view.code);
store.writes.length = 0;
return {...store,code,credentials,auths,ids,initial,save,otherCode:other.view.code,otherBefore};
}
type Fixture = Awaited<ReturnType<typeof persisted>>;
function snapshot(f: Fixture) {
const row = f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(f.code)!;
return {version:row.version,hash:createHash('sha256').update(String(row.state)).digest('hex')};
}
function draw(f:Fixture, ownRedraws=0):engine.Action {
return {type:'nexusCardChoice',turn:f.initial.turn,card:null,choice:'draw',ownRedraws};
}
async function restored(f:Fixture,g:engine.Game) {
const before=snapshot(f), rooms=f.restart();
for (const [i,token] of f.credentials.entries()) {
const auth=await rooms.authenticate(f.code,token);
const view=await rooms.readSeatView(f.code,auth);
assert.deepEqual(view,engine.viewGame(g,f.ids[i]));
const offer=view.nexusCards!;
assert.equal(offer.card,g.nexusCards!.cards!.hands[f.ids[i]]);
assert.equal(Object.hasOwn(offer,'deck'),false);
assert.equal(Object.hasOwn(offer,'discard'),false);
assert.equal(Object.hasOwn(offer,'hands'),false);
assert.equal(JSON.stringify(offer).includes('signature'),false);
if(i!==2) assert.equal(offer.card,null);
}
assert.deepEqual(snapshot(f),before);
assert.deepEqual(await rooms.readRoom(f.otherCode),f.otherBefore);
nexusInventory(g);
}
void test('Nexus SQL concurrent keep-own and redraw-own policies commit one physical result and one phase advance',async()=>{
const f=await persisted();
try {
let arrived=0; let release!:()=>void;
const waiting=new Promise<void>(resolve=>{release=resolve;});
f.hooks.beforeWrite=async()=>{if(++arrived===2)release();await waiting;};
const outcomes=await Promise.allSettled([
f.rooms.act(f.code,f.auths[2],f.initial.version,draw(f,0),clock),
f.restart().act(f.code,f.auths[2],f.initial.version,draw(f,2),clock),
]);
delete f.hooks.beforeWrite;
assert.equal(outcomes.filter(result=>result.status==='fulfilled').length,1);
assert.deepEqual(f.writes.map(write=>write.changes).sort((a,b)=>a-b),[0,1]);
const g=await f.rooms.readRoom(f.code);
assert.equal(g.version,f.initial.version+1);
assert.equal(g.phase,2);
assert.ok(['harkonnen','ecaz'].includes(g.nexusCards!.cards!.hands[f.ids[2]]!));
assert.equal(g.nexusCards!.phase!.stage,'complete');
const before=snapshot(f);
await assert.rejects(f.rooms.act(f.code,f.auths[2],f.initial.version,draw(f),clock));
await assert.rejects(f.rooms.act(f.code,f.auths[2],g.version,draw(f),clock),/Nexus/);
assert.deepEqual(snapshot(f),before);
await restored(f,g);
} finally {f.sqlite.close();}
});
void test('Nexus SQL lost draw response restores the exact private card without reroll or additional writes',async()=>{
const f=await persisted();
try {
await f.rooms.act(f.code,f.auths[2],f.initial.version,draw(f,2),clock);
const g=await f.restart().readRoom(f.code);
assert.equal(g.nexusCards!.cards!.hands[f.ids[2]],'ecaz');
assert.deepEqual(g.nexusCards!.cards!.discard,['harkonnen']);
const before=snapshot(f);f.writes.length=0;
await restored(f,g);
await f.restart().continueRoomAutomatic(f.code,clock);
assert.deepEqual(snapshot(f),before);assert.equal(f.writes.length,0);
for(const entry of g.log.filter(entry=>entry.text.includes('Nexus Card')))
assert.doesNotMatch(entry.text,/Ecaz|Harkonnen.*card identity/);
} finally {f.sqlite.close();}
});
void test('Nexus SQL invalid inventory and edited closing progress reject before storage or private projection',async()=>{
const f=await persisted();
try {
for(const corrupt of [
(g:engine.Game)=>{g.nexusCards!.cards!.deck.pop();},
(g:engine.Game)=>{g.nexusCards!.phase!.done.push(f.ids[2]);},
(g:engine.Game)=>{g.nexusCards!.phase!.occurred=false;},
(g:engine.Game)=>{g.nexusCards!.phase=null;},
]) {
const g=structuredClone(f.initial);corrupt(g);f.save(g);
const before=snapshot(f);f.writes.length=0;
await assert.rejects(f.rooms.readSeatView(f.code,f.auths[2]),/Nexus/);
await assert.rejects(f.rooms.act(f.code,f.auths[2],g.version,draw(f),clock),/Nexus/);
assert.deepEqual(snapshot(f),before);assert.equal(f.writes.length,0);
}
} finally {f.sqlite.close();}
});
+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);
});