Files
Dune/tests/discovery-playthrough.test.ts
T

223 lines
6.7 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { baseDeck, spiceDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import {
DISCOVERY_SPICE_CARDS,
DISCOVERY_TOKENS,
validateDiscoveryState,
} from '../game/discoveries';
import {
applyAction,
createGame,
initializeDiscoveryGameForAudit,
joinGame,
newPlayer,
type Game,
} from '../game/engine';
const ROSTER: [string, FactionId][] = [
['a', 'atreides'],
['h', 'harkonnen'],
['g', 'guild'],
['f', 'fremen'],
];
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
function generator(seed: number) {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let word = Math.imul(state ^ (state >>> 15), state | 1);
word ^= word + Math.imul(word ^ (word >>> 7), word | 61);
return (word ^ (word >>> 14)) >>> 0;
};
}
function withEngineSeed<T>(seed: number, run: () => T): T {
const cryptoObject = globalThis.crypto;
const originalRandom = Object.getOwnPropertyDescriptor(
cryptoObject,
'getRandomValues',
);
const originalUuid = Object.getOwnPropertyDescriptor(
cryptoObject,
'randomUUID',
);
const randomWord = generator(seed);
const uuidWord = generator(seed ^ 0xa5a5a5a5);
Object.defineProperty(cryptoObject, 'getRandomValues', {
configurable: true,
value: <T extends ArrayBufferView | null>(array: T): T => {
if (!(array instanceof Uint32Array))
throw new Error(
`Discovery playthrough seed ${seed} supports only the engine Uint32Array RNG contract.`,
);
for (let index = 0; index < array.length; index++)
array[index] = randomWord();
return array;
},
});
Object.defineProperty(cryptoObject, 'randomUUID', {
configurable: true,
value: () => {
const hex = Array.from({ length: 4 }, () =>
uuidWord().toString(16).padStart(8, '0'),
)
.join('')
.split('');
hex[12] = '4';
hex[16] = ((parseInt(hex[16], 16) & 3) | 8).toString(16);
const id = hex.join('');
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
},
});
try {
return run();
} finally {
if (originalRandom)
Object.defineProperty(cryptoObject, 'getRandomValues', originalRandom);
else Reflect.deleteProperty(cryptoObject, 'getRandomValues');
if (originalUuid)
Object.defineProperty(cryptoObject, 'randomUUID', originalUuid);
else Reflect.deleteProperty(cryptoObject, 'randomUUID');
}
}
function discoveryLobby(advanced: boolean): Game {
const [first, ...rest] = ROSTER;
let game = createGame(
'DISCOVERYPLAYTHROUGH',
newPlayer(first[0], first[0], first[1]),
advanced,
);
for (const [id, faction] of rest) joinGame(game, newPlayer(id, id, faction));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
game.discoveryEnabled = true;
return game;
}
function finishGame(initial: Game, seed: number): Game {
let game = initial;
for (let batch = 0; batch < 240 && game.status !== 'finished'; batch++) {
game = runBots(reload(game));
assert.ok(
game.status === 'finished' || game.botsPending,
`Discovery playthrough seed ${seed} stalled at turn ${game.turn}, phase ${game.phase}, setup ${game.setupStage ?? 'complete'}, decision ${game.decision?.kind ?? 'none'}.`,
);
}
assert.equal(
game.status,
'finished',
`Discovery playthrough seed ${seed} did not finish.`,
);
assert.ok(
game.winner.length > 0,
`Discovery playthrough seed ${seed} has no winner.`,
);
return reload(game);
}
function assertTreacheryInventory(game: Game, seed: number) {
const cards = [
...game.deck,
...game.discard,
...game.players.flatMap((player) => player.hand),
...(game.auction?.cards.slice(game.auction.index) ?? []),
];
const expected = baseDeck()
.map((card) => card.id)
.sort();
assert.equal(
cards.length,
expected.length,
`Treachery cards in seed ${seed}`,
);
assert.equal(
new Set(cards.map((card) => card.id)).size,
expected.length,
`Distinct Treachery cards in seed ${seed}`,
);
assert.deepEqual(
cards.map((card) => card.id).sort(),
expected,
`Treachery identities in seed ${seed}`,
);
}
function assertForceInventory(game: Game, seed: number) {
for (const player of game.players)
assert.equal(
player.reserves +
player.tanks +
Object.values(player.forces).reduce((sum, count) => sum + count, 0),
20,
`${player.id} force inventory in seed ${seed}`,
);
}
function assertDiscoveryInventory(game: Game, seed: number) {
validateDiscoveryState(game.discoveries!);
assert.equal(
game.discoveries!.tokens.length,
DISCOVERY_TOKENS.length,
`Discovery tokens in seed ${seed}`,
);
assert.deepEqual(
game.discoveries!.tokens.map((token) => token.face).sort(),
DISCOVERY_TOKENS.map((token) => token.id).sort(),
`Discovery token faces in seed ${seed}`,
);
const physicalSpiceCards = [
...game.spiceDeck,
...game.spiceDiscard.flat(),
].filter((card) => !('worm' in card && card.thumper));
assert.equal(
physicalSpiceCards.length + Number(game.sandtrout),
spiceDeck().length + 7,
`Spice Card inventory in seed ${seed}`,
);
const discoveryCards = physicalSpiceCards.flatMap((card) =>
'territory' in card && card.discovery ? [card.discovery] : [],
);
assert.deepEqual(
discoveryCards.sort(),
DISCOVERY_SPICE_CARDS.map((card) => card.discovery).sort(),
`Discovery Spice Cards in seed ${seed}`,
);
assert.equal(
physicalSpiceCards.filter((card) => 'worm' in card && card.greatMaker)
.length,
1,
`Great Maker card in seed ${seed}`,
);
}
for (const [advanced, seed] of [
[false, 2026091303],
[true, 2026091304],
] as const)
void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Discovery seed ${seed} completes through JSON bot batches with conserved pieces`, () =>
withEngineSeed(seed, () => {
const lobby = discoveryLobby(advanced);
let game = initializeDiscoveryGameForAudit(lobby);
game.players.forEach((player, index) => {
player.bot = DIFFICULTIES[index];
});
assert.equal(game.status, 'setup');
assert.equal(game.expansions.length, 0);
assert.equal(game.discoveryEnabled, true);
assert.equal(game.discoveries?.tokens.length, DISCOVERY_TOKENS.length);
assert.equal(game.spiceDeck.length, spiceDeck().length + 7);
game = finishGame(game, seed);
assertTreacheryInventory(game, seed);
assertForceInventory(game, seed);
assertDiscoveryInventory(game, seed);
}));