Files
Dune/tests/moritani-skills-fixture.ts
T

239 lines
7.0 KiB
TypeScript

import assert from 'node:assert/strict';
import { mock } from 'node:test';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
type Action,
type Game,
type Player,
} from '../game/engine';
import {
LEADER_SKILL_CARDS,
type LeaderSkillId,
} from '../game/leader-skill-cards';
import type { TerrorKind } from '../game/moritani-terror';
export const MORITANI_SKILLS_SEATS = {
moritani: 'm',
atreides: 'a',
emperor: 'e',
} as const;
export type MoritaniSkillsFaction = keyof typeof MORITANI_SKILLS_SEATS;
export type MoritaniSkillsFixtureOptions = {
requestedSkill?: LeaderSkillId;
skillOwner?: MoritaniSkillsFaction;
};
const names: Record<MoritaniSkillsFaction, string> = {
moritani: 'Moritani',
atreides: 'Atreides',
emperor: 'Emperor',
};
export const reloadMoritaniSkillsGame = (game: Game): Game =>
JSON.parse(JSON.stringify(game)) as Game;
export function moritaniSkillsPlayer(game: Game, id: string): Player {
return game.players.find((player) => player.id === id)!;
}
export function rejectMoritaniSkillsAction(
game: Game,
id: string,
action: Action,
pattern?: RegExp,
): void {
const before = structuredClone(game);
if (pattern) assert.throws(() => applyAction(game, id, action), pattern);
else assert.throws(() => applyAction(game, id, action));
assert.deepEqual(game, before);
}
export function assertMoritaniSkillsCustody(game: Game): void {
const skills = game.leaderSkills!;
const physical = [
...skills.deck,
...Object.values(skills.offers).flatMap((offer) => offer.cards),
...skills.assignments.map((assignment) => assignment.skill),
];
const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
assert.equal(physical.length, canonical.length);
assert.equal(new Set(physical).size, canonical.length);
assert.deepEqual([...physical].sort(), [...canonical].sort());
}
/**
* Reach the real private Leader Skill setup offers from a ready Basic lobby.
* Seating the requested owner first lets the production shuffle seam put that
* requested physical card at the front of its genuine two-card deal.
*/
export function initializedMoritaniSkillsOffers(
options: MoritaniSkillsFixtureOptions = {},
): Game {
const requestedSkill = options.requestedSkill ?? 'warmaster';
const skillOwner = options.skillOwner ?? 'moritani';
const factions = (
[skillOwner, 'moritani', 'atreides', 'emperor'] as MoritaniSkillsFaction[]
).filter((faction, index, all) => all.indexOf(faction) === index);
const first = factions[0];
let game = createGame(
'MORISKIL',
newPlayer(MORITANI_SKILLS_SEATS[first], names[first], first),
false,
['ecaz'],
);
for (const faction of factions.slice(1))
joinGame(
game,
newPlayer(MORITANI_SKILLS_SEATS[faction], names[faction], faction),
);
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex(
(card) => card.id === requestedSkill,
);
assert.ok(targetIndex >= 0);
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();
}
const owner = MORITANI_SKILLS_SEATS[skillOwner];
assert.equal(game.status, 'setup');
assert.equal(game.setupStage, 'leaderSkills');
assert.equal(game.leaderSkills!.offers[owner].cards[0], requestedSkill);
assertMoritaniSkillsCustody(game);
return game;
}
/** Complete skills, traitors, and Moritani's six-force placement by actions. */
export function completedMoritaniSkillsGame(
options: MoritaniSkillsFixtureOptions = {},
): Game {
const requestedSkill = options.requestedSkill ?? 'warmaster';
const skillOwner = options.skillOwner ?? 'moritani';
const requestedOwner = MORITANI_SKILLS_SEATS[skillOwner];
let game = initializedMoritaniSkillsOffers(options);
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 === requestedOwner ? requestedSkill : offer.cards[0],
leader: player.leaders[0].id,
});
}
assert.equal(game.setupStage, 'traitors');
for (const player of game.players) {
const choice = moritaniSkillsPlayer(game, player.id).traitorChoices[0];
assert.ok(choice);
game = applyAction(game, player.id, { type: 'traitor', leader: choice });
}
assert.deepEqual(game.decision, {
kind: 'moritaniSetup',
player: MORITANI_SKILLS_SEATS.moritani,
});
game = applyAction(game, MORITANI_SKILLS_SEATS.moritani, {
type: 'decision',
territory: 'polar_sink',
sector: 0,
});
assert.equal(game.status, 'playing');
assert.equal(game.setupStage, undefined);
assert.equal(moritaniSkillsPlayer(game, 'm').forces['polar_sink:0'], 6);
assert.equal(moritaniSkillsPlayer(game, 'm').reserves, 14);
assertMoritaniSkillsCustody(game);
return game;
}
/**
* Stage only the phase controls, then place a real hidden Terror token through
* the Mentat decision and its complete response window. Force custody is not
* changed by this helper.
*/
export function placeMoritaniSkillsTerror(
state: Game,
kind: TerrorKind,
territory = 'carthag',
): Game {
let game = structuredClone(state);
Object.assign(game, {
phase: 7,
active: null,
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
assert.equal(game.phase, 8);
assert.equal(game.decision?.kind, 'moritaniPlacement');
const token = game.moritaniTerror!.tokens.find(
(candidate) => candidate.kind === kind,
);
assert.ok(token);
game = applyAction(game, MORITANI_SKILLS_SEATS.moritani, {
type: 'decision',
token: token.id,
territory,
});
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.moritaniTerror!.tokens.find((candidate) => candidate.id === token.id)
?.location,
territory,
);
return game;
}
/** Stage Shipment and perform the entrant's real reserve shipment. */
export function enterMoritaniSkillsTerror(
state: Game,
entrant: string,
territory = 'carthag',
sector = 11,
amount = 1,
): Game {
const game = structuredClone(state);
Object.assign(game, {
turn: game.phase > 5 ? game.turn + 1 : game.turn,
phase: 5,
storm: 18,
active: entrant,
ready: [],
decision: null,
response: null,
phaseOpening: null,
movementRemaining: [...game.order],
});
const player = moritaniSkillsPlayer(game, entrant);
player.shipped = false;
player.moved = 0;
return applyAction(game, entrant, {
type: 'ship',
territory,
sector,
amount,
});
}