Compose World Skills cards with selected ordinary decks

This commit is contained in:
Quintine committed 2026-10-17 23:07:39 +11:00
1 parent b9330d489e
commit 01985af36b
36 files changed
+1201 -283

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
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 };
}