Files
Dune/tests/moritani-skills-worm.test.ts
T

213 lines
6.9 KiB
TypeScript

import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { sandmasterWormCollection } from '../game/sandmaster-worm';
function totalForces(game: Game, player: string) {
const owner = game.players.find((candidate) => candidate.id === player)!;
return (
owner.reserves +
owner.tanks +
Object.values(owner.forces).reduce((sum, count) => sum + count, 0)
);
}
const totalSpiceCards = (game: Game) =>
game.spiceDeck.length +
game.spiceDiscard.reduce((sum, pile) => sum + pile.length, 0);
/** Genuine Basic setup through ready, skill, traitor and both force decisions. */
function completedMoritaniFremenSkillsGame(): Game {
let game = createGame(
'MORIWORM',
newPlayer('f', 'Fremen', 'fremen'),
false,
['ecaz'],
);
joinGame(game, newPlayer('m', 'Moritani', 'moritani'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex(
(card) => card.id === 'sandmaster',
);
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
return array;
});
try {
game = initializeLeaderSkillsGameForAudit(game);
} finally {
mock.restoreAll();
}
assert.equal(game.leaderSkills!.offers.f.cards[0], 'sandmaster');
for (const player of game.players) {
const offer = game.leaderSkills!.offers[player.id];
game = applyAction(game, player.id, {
type: 'leaderSkill',
event: offer.event,
skill: player.id === 'f' ? 'sandmaster' : offer.cards[0],
leader: player.leaders[0].id,
});
}
for (const player of game.players) {
const current = game.players.find((candidate) => candidate.id === player.id)!;
game = applyAction(game, player.id, {
type: 'traitor',
leader: current.traitorChoices[0],
});
}
game = applyAction(game, 'f', {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
assert.deepEqual(game.decision, { kind: 'moritaniSetup', player: 'm' });
game = applyAction(game, 'm', {
type: 'decision',
territory: 'polar_sink',
sector: 0,
});
assert.equal(game.status, 'playing');
assert.deepEqual(
game.leaderSkills!.assignments.find((assignment) => assignment.owner === 'f'),
{ owner: 'f', leader: 'fremen-0', skill: 'sandmaster' },
);
return game;
}
function summonedWormRide(): Game {
let game = completedMoritaniFremenSkillsGame();
const fremen = game.players.find((player) => player.id === 'f')!;
// Stage a conserved turn-two Spice Blow position. Only locations, deck order
// and phase controls change; all twenty Fremen forces and Spice cards remain.
fremen.forces = { 'wind_pass_north:17': 4 };
fremen.reserves = 16;
fremen.moved = 1;
game.spice = { 'habbanya_ridge_flat:17': 2 };
const spiceCardCount = totalSpiceCards(game);
const priorIndex = game.spiceDeck.findIndex(
(card) => 'territory' in card && card.territory === 'wind_pass_north',
);
assert.ok(priorIndex >= 0);
const [prior] = game.spiceDeck.splice(priorIndex, 1);
const wormIndex = game.spiceDeck.findIndex((card) => 'worm' in card);
assert.ok(wormIndex >= 0);
const [worm] = game.spiceDeck.splice(wormIndex, 1);
const nextIndex = game.spiceDeck.findIndex(
(card) =>
'territory' in card &&
card.territory !== 'wind_pass_north' &&
card.territory !== 'habbanya_ridge_flat',
);
assert.ok(nextIndex >= 0);
const [next] = game.spiceDeck.splice(nextIndex, 1);
game.spiceDeck = [worm, next, ...game.spiceDeck];
game.spiceDiscard = [[prior], []];
assert.equal(totalSpiceCards(game), spiceCardCount);
Object.assign(game, {
turn: 2,
phase: 1,
storm: 18,
active: null,
ready: [],
decision: null,
response: null,
phaseOpening: null,
nexus: false,
spiceSequence: null,
spiceResolution: null,
spiceWindow: null,
wormRides: [],
movementRemaining: null,
});
assert.equal(totalForces(game, 'f'), 20);
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
while (game.response) {
const responder = game.players.find(
(player) => !game.response!.passed.includes(player.id),
);
assert.ok(responder);
game = applyAction(game, responder.id, { type: 'passResponse' });
}
assert.equal(game.nexus, true);
for (let round = 0; round < 3 && game.decision?.kind !== 'wormRide'; round += 1)
for (const player of game.players)
if (!game.ready.includes(player.id))
game = applyAction(game, player.id, { type: 'ready' });
assert.deepEqual(game.decision, {
kind: 'wormRide',
player: 'f',
territory: 'wind_pass_north',
});
assert.equal(totalForces(game, 'f'), 20);
return game;
}
void test('Basic Moritani permits a projected Sandmaster worm collection and one legal bot ride after JSON restoration', () => {
const pending = summonedWormRide();
const spiceCardCount = totalSpiceCards(pending);
const restored = JSON.parse(JSON.stringify(pending)) as Game;
assert.deepEqual(viewGame(restored, 'f'), viewGame(pending, 'f'));
const authoritative = sandmasterWormCollection(
restored,
'f',
'habbanya_ridge_flat',
17,
);
const projected = sandmasterWormCollection(
viewGame(restored, 'f'),
'f',
'habbanya_ridge_flat',
17,
);
assert.deepEqual(projected, authoritative);
const botView = viewGame(restored, 'f');
botView.players.find((player) => player.id === 'f')!.bot = 'Easy';
const actions = botActions(botView);
const collecting = actions.find(
(action) =>
action.accept === true &&
action.territory === 'habbanya_ridge_flat' &&
action.sector === 17 &&
action.sandmasterCollect === true,
);
assert.ok(
collecting,
`the minimal bot path should include the legal collection: ${JSON.stringify(actions)}`,
);
const beforeSpice = restored.players.find((player) => player.id === 'f')!.spice;
const done = applyAction(restored, 'f', collecting);
const rider = done.players.find((player) => player.id === 'f')!;
assert.equal(rider.spice, beforeSpice + 1);
assert.equal(done.spice['habbanya_ridge_flat:17'], 1);
assert.equal(rider.forces['wind_pass_north:17'] ?? 0, 0);
assert.equal(rider.forces['habbanya_ridge_flat:17'], 4);
assert.equal(rider.moved, 1);
assert.equal(totalForces(done, 'f'), 20);
assert.equal(totalSpiceCards(done), spiceCardCount);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Sandmaster collection',
).length,
1,
);
});