Files
Dune/tests/fixture-ix-starting-pool.ts

153 lines
9.7 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, initializeIxGameForAudit, initializeLeaderSkillsGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type IxStartingPoolStep = { actor: string; action: Action };
export type IxStartingPoolOptions = { advanced?: boolean; skills?: boolean; harkonnen?: boolean };
export const ixStartingPoolPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const ixStartingPoolReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
function permutation<T>(source: readonly T[], desired: readonly T[]): number[] {
const working = [...source], rolls: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(desired[index]); assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[working[index], working[from]] = [working[from], working[index]];
}
assert.deepEqual(working, desired); return rolls;
}
/** Only original shuffle draws are selected. UUID and byte entropy remain native. */
function entropy<T>(rolls: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actIxStartingPool(game: Game, actor: string, action: Action,
actions: IxStartingPoolStep[] = []): Game {
const next = entropy([], () => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Includes the still-private native draft; historical references are not slots. */
export function ixStartingPoolInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
...game.players.flatMap(player => player.hand), ...(game.auction?.cards.slice(game.auction.index) ?? [])]
.map(card => card.id).sort();
assert.deepEqual(cards, treacheryDeck(game.expansions).map(card => card.id).sort());
const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices])].sort();
if (traitors.length) assert.deepEqual(traitors, traitorDeck(game.players, true).sort());
const skills = game.leaderSkills ? [...game.leaderSkills.deck,
...game.leaderSkills.assignments.map(row => row.skill),
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort() : [];
if (game.leaderSkills) {
validateLeaderSkills(game.leaderSkills, game.players);
assert.deepEqual(skills, LEADER_SKILL_CARDS.map(card => card.id).sort());
}
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0), player.faction === 'ixians' ? 7 : 5);
}
return { cards, traitors, skills };
}
/** The original Ix packet is deliberately caller-owned; no draft choice is guessed. */
export function nextIxStartingPoolStep(game: Game): IxStartingPoolStep | null {
if (game.decision?.kind === 'ixSetup') return null;
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const skill = own.offer!.cards.find(card => !own.unavailableSkills?.[card]);
const leader = own.eligibleLeaders[0]; assert.ok(skill && leader);
return { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceIxStartingPool(game: Game, until: (state: Game) => boolean,
actions: IxStartingPoolStep[] = []): Game {
for (let count = 0; count < 200; count++) {
if (until(game)) return game;
const step = nextIxStartingPoolStep(game); assert.ok(step, 'Choose the actual private Ix starting card before setup continues');
game = actIxStartingPool(game, step.actor, step.action, actions);
}
throw Error('Original Ix starting-card setup did not reach its finite boundary');
}
export function ixStartingPoolChoice(game: Game, card: string): Action {
const own = viewGame(game, 'ixians');
assert.equal(game.decision?.kind, 'ixSetup');
assert.ok(own.ixTechnology?.setup?.some(candidate => candidate.id === card));
return { type: 'decision', card };
}
export function finishIxStartingPool(game: Game, actions: IxStartingPoolStep[] = []): Game {
return advanceIxStartingPool(game, state => state.status === 'playing', actions);
}
/** Original fresh Ix/Hark/Atreides setup, or Emperor in the no-Hark control.
* The supported Ix or all14 preview initializer is used, not a public start.
* Only initial shuffle entropy is selected. No dealt hand, phase, force, purse,
* pending receipt or module flag is installed after initialization.
*/
export function createIxStartingPoolFixture(options: IxStartingPoolOptions = {}) {
const advanced = options.advanced ?? true, skills = options.skills ?? false, harkonnen = options.harkonnen ?? true;
const second = harkonnen ? 'harkonnen' : 'emperor';
let game = createGame('IXSTARTINGPOOL', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
joinGame(game, newPlayer(second, second, second));
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
const initial = structuredClone(game), actions: IxStartingPoolStep[] = [];
for (const player of game.players) game = actIxStartingPool(game, player.id, { type: 'ready' }, actions);
const lobby = structuredClone(game), original = treacheryDeck(['ix']);
const prefix = [original.find(card => card.kind === 'projectile')!, original.find(card => card.kind === 'shield')!,
original.find(card => card.kind === 'snooper')!, original.find(card => card.effect === 'karama')!];
const deckOrder = [...prefix.map(card => card.id), ...original.filter(card => !prefix.some(first => first.id === card.id)).map(card => card.id)];
const rolls = [...(skills ? Array<number>(LEADER_SKILL_CARDS.length - 1).fill(0xffffffff) : []),
...permutation(original.map(card => card.id), deckOrder)];
game = entropy(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeIxGameForAudit(game));
const initialized = structuredClone(game);
game = advanceIxStartingPool(game, state => state.decision?.kind === 'ixSetup', actions);
assert.equal(game.status, 'setup'); assert.equal(game.decision?.player, 'ixians');
assert.ok(game.players.every(player => !player.hand.length));
assert.ok(!game.homeworlds && !game.nexusCards && !game.discoveryEnabled && !game.techTokens && !game.strongholdCards && !game.ecazTreachery);
assert.equal(!!game.leaderSkills, skills);
assert.deepEqual(game.ixSetupCards!.map(card => card.id), deckOrder.slice(0, game.ixSetupCards!.length));
const stock = ixStartingPoolInventory(game);
return { initial, lobby, initialized, game, advanced, skills, harkonnen, second,
pool: structuredClone(game.ixSetupCards!), prefix: prefix.map(card => card.id), extra: prefix[3].id,
nextAfterFour: deckOrder[4], deckOrder, stock, actions,
readyActions: actions.filter(step => step.action.type === 'ready'),
setupActions: actions.filter(step => step.action.type !== 'ready'),
sourceCounts: { ready: actions.filter(step => step.action.type === 'ready').length,
beforeDraft: actions.filter(step => step.action.type !== 'ready').length, source: actions.length },
sourceDisclosure: 'Genuine fresh Ix/Harkonnen/Atreides (or Emperor control) preview initialization, with optional original all14 Skills. Actual Ready/setup actions are recorded; initializer is not an action or public-start acceptance. Only original initial skill/Treachery shuffle entropy selected. Source stops before the actual Ix choice; no hand, army, purse, phase or receipt is installed.' };
}
/** Conserved old-count UNIT supplement, not a native historical save claim.
* Move only the untouched extra fourth card back to the undealt top, restoring
* the old Advanced N-card boundary without minting/dropping any physical card.
*/
export function createLegacyIxStartingPoolSupplement(skills = false) {
const base = createIxStartingPoolFixture({ advanced: true, skills });
const game = ixStartingPoolReload(base.game);
assert.equal(game.ixSetupCards!.length, game.players.length + 1);
const extra = game.ixSetupCards!.pop()!;
game.deck.unshift(extra);
assert.deepEqual(ixStartingPoolInventory(game), base.stock);
return { ...base, game, pool: structuredClone(game.ixSetupCards!),
sourceDisclosure: `${base.sourceDisclosure} Controlled conserved old-count unit supplement: the untouched fourth pool card returns to the undealt top to represent the earlier pending N-card contract. This is not claimed native old setup history.` };
}