213 lines
6.9 KiB
TypeScript
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,
|
|
);
|
|
});
|