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,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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();}
|
||||
});
|
||||
@@ -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