Files
Dune/tests/fixture-recruits-homeworld.ts
T

179 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nativeShipmentSources } from '../game/homeworld-options';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
import { takeHarassCard } from './fixture-harass-withdraw';
import { skillOfferRolls,
type IndependentCardModulesOptions } from './fixture-independent-card-modules';
export type RecruitsHomeworldStep = { actor: string; action: Action };
export type RecruitsHomeworldFixture = {
initial: Game;
afterSetup: Game;
game: Game;
nativeSteps: RecruitsHomeworldStep[];
staging: string[];
};
export const recruitsHomeworldPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
};
export const recruitsHomeworldClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
export function advanceRecruitsHomeworld(game: Game, until: (game: Game) => boolean,
steps?: RecruitsHomeworldStep[]): Game {
for (let n = 0; n < 2000; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'Native programme must reach its requested Revival');
const step = nextHarassHomeworldStep(game);
assert.ok(step, 'Native continuation requires a real battle plan');
steps?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
throw Error('Native World Recruits programme did not reach its boundary');
}
export function assertRecruitsHomeworldInventory(game: Game): void {
homeworldGameIntegrity(game);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a, b) => a + b, 0), 20);
assert.ok(Object.keys(player.forces).every(key => !key.startsWith('homeworld:')));
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((a, b) => a + b, 0), player.faction === 'fremen' ? 3 : 5);
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck([]), ...ecazTreacheryCards()].map(c => c.id).sort());
}
/** Fresh classic four-seat setup; no phase, population, wallet, dead-counter or
* revival receipt assignment. Only original hands/unopened Spice Cards are
* positioned, then actual shipments and two zero-dial battles create the dead. */
export function createRecruitsHomeworldFixture(options: {
advanced?: boolean;
seatIds?: readonly [string, string, string, string];
initial?: Game;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
} = {}): RecruitsHomeworldFixture {
const ids = options.seatIds ?? ['emperor', 'fremen', 'atreides', 'guild'];
let game = options.initial ? structuredClone(options.initial) :
createGame('RECRUITSWORLD', newPlayer(ids[0], 'Emperor', 'emperor'), options.advanced ?? false);
if (!options.initial) for (const [index, faction] of ['fremen', 'atreides', 'guild'].entries())
joinGame(game, newPlayer(ids[index + 1], faction, faction as 'fremen' | 'atreides' | 'guild'));
assert.equal(game.status, 'lobby'); assert.deepEqual(game.expansions, []);
assert.deepEqual(game.players.map(p => p.faction), ['emperor', 'fremen', 'atreides', 'guild']);
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
const nativeSteps: RecruitsHomeworldStep[] = [];
const act = (actor: string, action: Action) => {
nativeSteps.push({ actor, action: structuredClone(action) }); game = applyAction(game, actor, action);
};
for (const p of game.players) if (!p.ready) act(p.id, { type: 'ready' });
const initial = structuredClone(game);
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
let cursor = 0;
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length)
array[0] = rolls[cursor++];
else new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try {
if (options.skills) {
game.ecazTreachery = true;
game = initializeLeaderSkillsGameForAudit(game);
} else game = initializeEcazTreacheryGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!, player = recruitsHomeworldPlayer(game, actor);
const skill = options.training?.[player.faction] ?? offer.cards.find(card => !own.unavailableSkills?.[card]);
const leader = own.eligibleLeaders[0];
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader);
act(actor, { type: 'leaderSkill', event: offer.event, skill, leader: leader.id });
} else {
const step = nextHarassHomeworldStep(game); assert.ok(step);
act(step.actor, step.action);
}
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const staging = ['Return initial real hands to the unopened deck; no battle weapons, fake counters or wallet assignments.'];
if (options.skills)
staging.push('Select original all14 Skills before initialization of the supplied undealt lobby; assign actual offers without replacing seat identities, Worlds or original phase modules.');
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
// Original non-worm cards keep the first Blow away from an unrelated riding
// decision. This is not a fabricated Spice Card or phase assignment.
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
const at = game.spiceDeck.findIndex((card, index) => index >= i && 'territory' in card && !card.discovery);
assert.ok(at >= 0); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
}
staging.push('Position unopened original first-Blow non-worm cards; native later decks and history stay.');
const shipments = new Map<string, { amount: number; elite: number; territory: string; sector: number }>([
[ids[0], { amount: 3, elite: 0, territory: 'sietch_tabr', sector: 14 }],
[ids[1], { amount: 8, elite: 3, territory: 'sietch_tabr', sector: 14 }],
[ids[3], { amount: 3, elite: 0, territory: 'arrakeen', sector: 10 }],
]);
game = advanceRecruitsHomeworld(game, g => g.phase === 5 && recruitsHomeworldClean(g), nativeSteps);
while (game.phase === 5) {
const actor = game.active!;
const request = recruitsHomeworldClean(game) ? shipments.get(actor) : undefined;
if (request) {
const sources = nativeShipmentSources(viewGame(game, actor), request.amount, request.elite);
assert.ok(sources, 'Original typed shipment must have native counters');
act(actor, { type: 'ship', ...request, homeworldSources: sources });
shipments.delete(actor);
} else {
const step = nextHarassHomeworldStep(game); assert.ok(step); act(step.actor, step.action);
}
}
game = advanceRecruitsHomeworld(game, g => g.phase === 6 && recruitsHomeworldClean(g), nativeSteps);
while (game.phase === 6) {
if (recruitsHomeworldClean(game) && !game.battle && game.active) {
const choice = viewGame(game, game.active).battleChoices[0];
assert.ok(choice, 'Actual invaded stronghold must expose its battle choice');
act(choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
}
if (game.battle && !game.battle.revealed) {
game = advanceRecruitsHomeworld(game, g => recruitsHomeworldClean(g) &&
!g.battle?.preparation && g.battle?.preLeader?.closed !== false, nativeSteps);
const battle = game.battle!;
for (const actor of [battle.attacker, battle.defender]) {
if (game.battle!.plans[actor]) continue;
const p = recruitsHomeworldPlayer(game, actor);
const leaders = p.leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength);
const leader = actor === ids[0] || actor === ids[3] ? leaders.at(-1) : leaders[0]; assert.ok(leader);
act(actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
game = advanceRecruitsHomeworld(game, g => recruitsHomeworldClean(g) &&
!g.battle?.preparation && g.battle?.preLeader?.closed !== false, nativeSteps);
}
} else {
const step = nextHarassHomeworldStep(game); assert.ok(step); act(step.actor, step.action);
}
}
game = advanceRecruitsHomeworld(game, g => g.turn === 2 && g.phase === 4 && recruitsHomeworldClean(g), nativeSteps);
// Conserve the actual independent card after real casualties, not a fabricated
// draw/auction claim. Its holder is explicit in all smoke/test descriptions.
takeHarassCard(game, ids[0], RECRUITS_CARD_ID);
staging.push('Conserve the existing physical Recruits from its original zone to Emperor at quiet second-turn Revival.');
assert.equal(recruitsHomeworldPlayer(game, ids[1]).reserves, 2);
assert.equal(recruitsHomeworldPlayer(game, ids[1]).tanks, 18);
assert.equal(recruitsHomeworldPlayer(game, ids[2]).tanks, 10);
assertRecruitsHomeworldInventory(game);
return { initial, afterSetup, game, nativeSteps, staging };
}