Files
Dune/tests/nexus-card-phase.test.ts

239 lines
8.1 KiB
TypeScript

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);
});