179 lines
10 KiB
TypeScript
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 };
|
|
}
|