Files
Dune/tests/fixture-nexus-cards.ts
T

189 lines
5.6 KiB
TypeScript

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' | 'guild' | 'emperor';
} = {},
): 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' | 'guild' | 'emperor';
} = {},
): 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);
}