Files
Dune/tests/fixture-selected-deck-skills.ts
T

219 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, nativeHandLimit, newPlayer, viewGame,
type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { treacheryDeck, type Card } from '../game/cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nextChoamIndependentSkillsStep } from './fixture-choam-independent-skills';
import { singleNexusE1Clean, singleNexusE1Player, shipSingleNexusE1,
openSingleNexusE1Battle, revealSingleNexusE1Plans, quoteSingleNexusE1Battle } from './fixture-single-nexus-e1';
import { skillOfferRolls } from './fixture-independent-card-modules';
export type SelectedDeckSkillsStep = { actor: string; action: Action };
export type SelectedDeckSkillsOptions = {
advanced?: boolean;
homeworlds?: boolean;
factions?: readonly FactionId[];
expansions?: Game['expansions'];
/** The first seat's confirmed module technique; later seats share the rest. */
skill?: LeaderSkillId;
/** Play conserved original poison weapons: only the attacker's, or both. */
poison?: 'attacker' | 'both';
};
export const selectedDeckSkillsPlayer = singleNexusE1Player;
export const selectedDeckSkillsClean = singleNexusE1Clean;
export const selectedDeckSkillsQuote = quoteSingleNexusE1Battle;
/** Ordinary techniques that need no card of their own to change a battle. */
const OTHER_SKILLS: readonly LeaderSkillId[] = ['killer-medic', 'swordmaster-of-ginaz',
'prana-bindu-adept', 'sandmaster', 'warmaster'];
/** Entropy selection is confined to the original setup lottery. */
function originalLottery<T>(draws: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = draws[cursor++] ?? 0xffffffff;
else if (array) new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Move one conserved physical face out of the real remaining deck. No face is
* ever minted, purchased or replaced, and the printed hand limit still binds. */
function conserveCard(game: Game, actor: string, matches: (card: Card) => boolean,
staging: string[]): Card {
const player = selectedDeckSkillsPlayer(game, actor);
const index = game.deck.findIndex(matches);
assert.ok(index >= 0, 'The conserved original face must still wait in the real deck');
while (player.hand.length >= nativeHandLimit(player)) {
const at = player.hand.findIndex(card => !card.id.startsWith('richese-'));
assert.ok(at >= 0, `${actor}'s conserved cache faces leave no deckable hand room`);
const [card] = player.hand.splice(at, 1);
game.deck.push(card);
staging.push(`Conserved ${card.id}: ${actor}'s hand → remaining deck to keep the printed hand limit`);
}
const [card] = game.deck.splice(index, 1);
player.hand.push(card);
staging.push(`Conserved ${card.id}: remaining deck → ${actor}'s hand (no purchase, payment or replacement)`);
return card;
}
/** The ordinary lobby controls a real host uses: select the module, ready every
* seat, start, then confirm the two private offers. No development initializer,
* no expansion seat beyond the selected decks and no independent card module. */
export function createSelectedDeckSkillsSetup(options: SelectedDeckSkillsOptions = {}) {
const factions = options.factions ?? ['ixians', 'richese', 'guild'];
assert.ok(factions.length >= 2 && new Set(factions).size === factions.length);
let game = createGame('SELECTEDDECKSKILLS', newPlayer(factions[0], factions[0], factions[0]),
options.advanced ?? false, options.expansions ?? ['ix', 'choam']);
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
const actions: SelectedDeckSkillsStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action }); game = applyAction(game, actor, action);
};
const initial = structuredClone(game);
if (options.homeworlds) act(game.host, { type: 'homeworlds', enabled: true });
act(game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const owner = game.players[0].id;
const chosen: LeaderSkillId[] = [options.skill ?? 'mentat'];
while (chosen.length < game.players.length)
chosen.push(OTHER_SKILLS.find(skill => !chosen.includes(skill))!);
const training = Object.fromEntries(game.players.map((player, index) => [player.faction, chosen[index]]));
game = originalLottery(skillOfferRolls(game, training), () => {
const action: Action = game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' };
actions.push({ actor: game.host, action }); return applyAction(game, game.host, action);
});
const started = structuredClone(game);
let ixOffer: { before: Game; step: SelectedDeckSkillsStep; after: Game } | null = null;
if (game.decision?.kind === 'ixSetup') {
const actor = game.decision.player, before = structuredClone(game);
const card = viewGame(game, actor).ixTechnology!.setup![0]; assert.ok(card);
const step: SelectedDeckSkillsStep = { actor, action: { type: 'decision', card: card.id } };
act(actor, step.action); ixOffer = { before, step, after: structuredClone(game) };
staging.push('The Ix seat drafted its ordinary Treachery Card before any module technique was confirmed.');
}
const offered = structuredClone(game), trainers: Record<string, string> = {};
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const skill = chosen[game.players.findIndex(player => player.id === actor)];
assert.ok(own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
const eligible = new Set(own.eligibleLeaders.map(leader => leader.id));
const leader = selectedDeckSkillsPlayer(game, actor).leaders.filter(disc => eligible.has(disc.id))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader, `The ${actor} seat still has an eligible original disc`);
trainers[actor] = leader.id;
act(actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id });
} else {
const step = nextChoamIndependentSkillsStep(game);
assert.ok(step, `Consume the original ${game.setupStage ?? game.decision?.kind} setup window`);
act(step.actor, step.action);
}
}
assert.equal(game.status, 'playing');
const seats: Partial<Record<FactionId, string>> = {};
for (const player of game.players) seats[player.faction] = player.id;
return { initial, started, offered, ixOffer, afterSetup: structuredClone(game),
owner, seats, trainers, skills: chosen, actions, staging };
}
/** Pauses at unsealed plans and typed physical choices; only real native
* controls advance the chronology. */
export function advanceSelectedDeckSkills(game: Game, until: (g: Game) => boolean,
actions: SelectedDeckSkillsStep[] = []): Game {
for (let count = 0; count < 3000; count++) {
if (until(game)) return game;
assert.equal(game.status, 'playing');
const step = nextChoamIndependentSkillsStep(game);
assert.ok(step, `Supply the real ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
actions.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
throw Error('The bare selected-deck chronology did not reach its requested boundary');
}
/** All cards come from the selected ordinary decks plus a seated Richese cache;
* the Auditor is a leader disc and no Traitor face is minted or dropped. */
export function assertSelectedDeckSkillsInventory(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
if (game.homeworlds) homeworldGameIntegrity(game);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
const original = [...treacheryDeck(game.expansions),
...(game.players.some(player => player.faction === 'richese') ? richeseCards() : [])];
assert.deepEqual(cards.map(card => card.id).sort(), original.map(card => card.id).sort());
for (const player of game.players) {
assert.ok(player.hand.length <= nativeHandLimit(player));
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0),
player.faction === 'ixians' ? 7 : player.faction === 'emperor' ? 5 : 3);
}
if (game.status !== 'lobby' && game.status !== 'setup') assert.deepEqual([
...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors),
...game.players.flatMap(player => (player.faceDancers ?? []).map(card => card.leader)),
].sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
}
/** A paid shipment into the untouched Guild army, real posture choices and both
* sealed plans. No hand staging beyond conserved faces and no invented spice. */
export function createSelectedDeckSkillsProgramme(options: SelectedDeckSkillsOptions = {}) {
const setup = createSelectedDeckSkillsSetup(options), actions = setup.actions;
const guild = setup.seats.guild;
assert.ok(guild, 'The programme needs a real Guild seat to attack');
let game = advanceSelectedDeckSkills(setup.afterSetup,
g => g.phase === 5 && g.active === setup.owner && selectedDeckSkillsClean(g), actions);
const attacked = options.poison ? conserveCard(game, setup.owner, card => card.kind === 'poison',
setup.staging) : undefined;
const defended = options.poison === 'both'
? conserveCard(game, guild, card => card.kind === 'poison' && card.id !== attacked!.id, setup.staging)
: undefined;
const shipment = shipSingleNexusE1(game, setup.owner, 'tueks_sietch', 2, 0, actions);
game = openSingleNexusE1Battle(shipment.after, setup.owner, guild, 'tueks_sietch', actions);
const beforePlans = structuredClone(game);
const defender = selectedDeckSkillsPlayer(game, guild).leaders
.filter(disc => !disc.dead && disc.id !== setup.trainers[guild])
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(defender, 'The Guild seat still holds an untrained original disc');
game = revealSingleNexusE1Plans(game, [
{ actor: setup.owner, action: { type: 'battlePlan', leader: setup.trainers[setup.owner], dial: 0,
...(attacked ? { weapon: attacked.id } : {}) } },
{ actor: guild, action: { type: 'battlePlan', leader: defender.id, dial: 0,
...(defended ? { weapon: defended.id } : {}) } },
], actions);
return { ...setup, guild, shipment, beforePlans, game, defender: defender.id,
weapons: [attacked?.id, defended?.id].filter((id): id is string => !!id) };
}
/** Original poison weapons kill both committed discs for real, then the same
* bare table continues into the actual Revival phase of the next turn. */
export function createSelectedDeckSkillsRevivalCase(options: { advanced?: boolean } = {}) {
const programme = createSelectedDeckSkillsProgramme({ advanced: options.advanced,
factions: ['tleilaxu', 'guild', 'ixians'], skill: 'mentat', poison: 'both' });
const tleilaxu = programme.seats.tleilaxu;
assert.ok(tleilaxu, 'The revival case needs a real Tleilaxu seat');
const actions = programme.actions;
let game = advanceSelectedDeckSkills(programme.game,
g => !g.battle && selectedDeckSkillsClean(g), actions);
const guild = selectedDeckSkillsPlayer(game, programme.guild);
const dead = guild.leaders.find(disc => disc.id === programme.defender)!;
const trainer = selectedDeckSkillsPlayer(game, tleilaxu).leaders
.find(disc => disc.id === programme.trainers[tleilaxu])!;
assert.equal(dead.dead, true, 'The attacker’s conserved poison kills the committed Guild disc');
assert.equal(trainer.dead, true, 'The defender’s conserved poison kills the committed Tleilaxu disc');
assert.ok(guild.leaders.filter(disc => !disc.dead).length > 0);
game = advanceSelectedDeckSkills(game,
g => g.turn === 2 && g.phase === 4 && g.status === 'playing' && selectedDeckSkillsClean(g), actions);
return { ...programme, tleilaxu, actions, game, dead: dead.id, trainer: trainer.id,
advanced: programme.afterSetup.advanced };
}