Files
Dune/tests/fixture-ix-independent-skills.ts

281 lines
18 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, joinGame, newPlayer, viewGame,
type Action, type Game, type Player,
} from '../game/engine';
import { treacheryDeck, type Card } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { traitorDeck } from '../game/traitors';
import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { forceRevivalQuote } from '../game/revival';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
export type IxIndependentSkillsStep = { actor: string; action: Action };
export type IxIndependentSkillsOptions = { advanced?: boolean; tech?: boolean; strongholds?: boolean };
export const ixIndependentSkillsPlayer = (game: Game, actor: string): Player => {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
};
export const ixIndependentSkillsClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
/** Scoped real shuffle entropy, restored even when a native action rejects. */
function entropy<T>(run: () => T, rolls: readonly number[] = []): 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) {
for (let i = 0; i < array.length; i++) array[i] = 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');
}
}
function skillRolls(): number[] {
const source = LEADER_SKILL_CARDS.map(c => c.id);
const chosen: LeaderSkillId[] = ['suk-graduate', 'killer-medic', 'master-of-assassins'];
const rest = source.filter(c => !chosen.includes(c));
const desired = chosen.flatMap(c => [c, rest.shift()!]).concat(rest);
const work = [...source], rolls: number[] = [];
for (let i = work.length - 1; i > 0; i--) {
const from = work.indexOf(desired[i]); assert.ok(from >= 0 && from <= i);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1)));
[work[i], work[from]] = [work[from], work[i]];
}
return rolls;
}
export function stepIxIndependentSkills(game: Game, step: IxIndependentSkillsStep): Game {
return entropy(() => applyAction(game, step.actor, step.action));
}
/** Original private projection and real policy; never alter the human lobby seat. */
export function ixIndependentSkillsPolicyActions(game: Game, actor: string, difficulty: Difficulty): Action[] {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
return entropy(() => botActions(view));
}
/** Stop at allocations and rescue: these remain actual projected human controls. */
export function nextIxIndependentSkillsStep(game: Game): IxIndependentSkillsStep | null {
if (!game.phaseOpening && !game.response) {
const d = game.decision;
if (d?.kind === 'techToken') return { actor: d.player,
action: { type: 'decision', token: d.choices[0] } };
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player,
action: { type: 'leaderSkillVisibility', event: d.event, hide: d.player === 'ixians' } };
if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } };
if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
if (d?.kind === 'mentatQuestion') return { actor: d.player,
action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'rihani') return { actor: d.player,
action: { type: 'decision', event: d.event, draw: false } };
if (d?.kind === 'sukRescue' || d?.kind === 'harassWithdraw') return null;
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceIxIndependentSkills(state: Game, until: (game: Game) => boolean,
actions: IxIndependentSkillsStep[] = []): Game {
let game = structuredClone(state);
for (let i = 0; i < 1600; i++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextIxIndependentSkillsStep(game); assert.ok(step, 'Supply the original private plan, typed return or rescue');
actions.push(structuredClone(step)); game = stepIxIndependentSkills(game, step);
}
throw Error('Native independent programme did not reach its requested boundary');
}
/** Only physical card custody is staged; no stock, forces, wallets or receipts. */
function conserveCard(game: Game, actor: string, id: string, staging: string[]): Card {
const p = ixIndependentSkillsPlayer(game, actor), held = p.hand.find(c => c.id === id);
if (held) return held;
const zones = [{ label: 'remaining original deck', cards: game.deck },
{ label: 'original discard', cards: game.discard },
...game.players.filter(other => other.id !== actor).map(other => ({ label: other.id, cards: other.hand }))];
const source = zones.find(zone => zone.cards.some(c => c.id === id)); assert.ok(source);
if (p.hand.length >= handLimit(p)) {
const returned = p.hand.shift()!; game.deck.push(returned);
staging.push(`Conserved ${returned.id}: ${actor} hand → original remaining deck for printed hand capacity`);
}
const card = source.cards.splice(source.cards.findIndex(c => c.id === id), 1)[0];
p.hand.push(card); staging.push(`Conserved ${id}: ${source.label} → ${actor} hand; not a purchase or native deal`);
return card;
}
export function assertIxIndependentSkillsCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(),
[...treacheryDeck(['ix']), ...ecazTreacheryCards()].map(c => c.id).sort());
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
assert.ok([p.reserves, p.tanks, ...Object.values(p.forces)].every(n => Number.isSafeInteger(n) && n >= 0));
assert.ok(p.hand.length <= handLimit(p));
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), 7);
assert.ok([p.elites.reserves, p.elites.tanks, ...Object.values(p.elites.forces)]
.every(n => Number.isSafeInteger(n) && n >= 0));
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
}
}
if (game.status === 'playing') assert.deepEqual([
...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors),
...game.players.flatMap(p => (p.faceDancers ?? []).map(c => c.leader)),
].sort(), traitorDeck(game.players, true).sort());
}
export type IxIndependentSkillsFixture = {
initial: Game; beforeIxCard: Game; ixCard: IxIndependentSkillsStep; offered: Game; afterSetup: Game;
beforeShipment: Game; shipment: IxIndependentSkillsStep; afterShipment: Game;
move: IxIndependentSkillsStep; afterMove: Game; opponentMove: IxIndependentSkillsStep;
beforeBattle: Game; battleStep: IxIndependentSkillsStep;
/** Original before-plan window, not a synthetic revealed Battle. */
game: Game; plans: IxIndependentSkillsStep[]; returns: HarassWithdrawSelection;
ixians: string; tleilaxu: string; opponent: string; trainer: string;
first: string; second: string; actions: IxIndependentSkillsStep[]; staging: string[];
};
/** Normal fresh lobby start → original Ix card-before-training → native armies →
* real desert shipment and split movement. Only the two singleton cards move zones. */
export function createIxIndependentSkillsFixture(options: IxIndependentSkillsOptions = {}): IxIndependentSkillsFixture {
const advanced = options.advanced ?? false;
let game = createGame('IXINDEPENDENTSKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
const initial = structuredClone(game), actions: IxIndependentSkillsStep[] = [], staging: string[] = [];
const record = (step: IxIndependentSkillsStep): Game => {
actions.push(structuredClone(step)); game = stepIxIndependentSkills(game, step);
return game;
};
for (const [type, enabled] of [
['techTokens', options.tech ?? false], ['strongholdCards', options.strongholds ?? false],
] as const) if (enabled) record({ actor: game.host, action: { type, enabled } });
for (const option of ['leaderSkills', 'ecazTreachery'] as const)
record({ actor: game.host, action: { type: 'moduleOption', module: option, enabled: true } });
for (const p of game.players) record({ actor: p.id, action: { type: 'ready' } });
const start: IxIndependentSkillsStep = { actor: game.host,
action: { type: 'start', ...(advanced ? { advancedPreview: true } : {}) } };
actions.push(start); game = entropy(() => applyAction(game, start.actor, start.action), skillRolls());
staging.push('Scoped/restored original all14 lottery and canonical native setup shuffle; no offer or deck replacement');
assert.equal(game.setupStage, 'skillTreachery'); assert.equal(game.decision?.kind, 'ixSetup');
const beforeIxCard = structuredClone(game);
const ixCard: IxIndependentSkillsStep = { actor: 'ixians', action: { type: 'decision', card: game.ixSetupCards![0].id } };
game = record(ixCard); const offered = structuredClone(game);
const chosen: Record<string, LeaderSkillId> = { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'master-of-assassins' };
for (let i = 0; game.status === 'setup' && i < 120; i++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
assert.ok(offer.cards.includes(chosen[actor]));
const view = viewGame(game, actor).leaderSkills!;
const leader = ixIndependentSkillsPlayer(game, actor).leaders
.filter(l => view.eligibleLeaders.some(e => e.id === l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader); record({ actor, action: { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id } });
} else { const step = nextIxIndependentSkillsStep(game); assert.ok(step); record(step); }
}
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
const trainer = game.leaderSkills!.assignments.find(a => a.owner === 'ixians')!.leader;
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g), actions);
const beforeShipment = structuredClone(game), first = 'pasty_mesa:5', second = 'pasty_mesa:6';
const shipment: IxIndependentSkillsStep = { actor: 'ixians', action: {
type: 'ship', territory: 'pasty_mesa', sector: 5, amount: 4, elite: 2, allyPayment: 0 } };
record(shipment);
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g) && !g.pendingShipment, actions);
const afterShipment = structuredClone(game);
const move: IxIndependentSkillsStep = { actor: 'ixians', action: {
type: 'move', from: first, amount: 2, elite: 1, territory: 'pasty_mesa', sector: 6 } };
record(move);
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'ixians' && ixIndependentSkillsClean(g), actions);
const afterMove = structuredClone(game); record({ actor: 'ixians', action: { type: 'endMovement' } });
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'guild' && ixIndependentSkillsClean(g), actions);
const opponentMove: IxIndependentSkillsStep = { actor: 'guild', action: {
type: 'move', from: 'tueks_sietch:5', amount: 5, territory: 'pasty_mesa', sector: 6 } };
record(opponentMove);
game = advanceIxIndependentSkills(game, g => g.phase === 5 && g.active === 'guild' && ixIndependentSkillsClean(g), actions);
record({ actor: 'guild', action: { type: 'endMovement' } });
game = advanceIxIndependentSkills(game, g => g.phase === 6 && ixIndependentSkillsClean(g), actions);
conserveCard(game, 'ixians', REINFORCEMENTS_CARD, staging);
conserveCard(game, 'ixians', HARASS_WITHDRAW_CARD, staging);
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === 'pasty_mesa'); assert.ok(choice);
const battleStep: IxIndependentSkillsStep = { actor: choice.chooser,
action: { type: 'chooseBattle', territory: 'pasty_mesa', target: choice.defender } };
record(battleStep);
game = advanceIxIndependentSkills(game, g => !!g.battle && !g.battle.revealed && ixIndependentSkillsClean(g), actions);
const enemy = ixIndependentSkillsPlayer(game, 'guild').leaders
.filter(l => !l.dead && l.id !== game.leaderSkills!.assignments.find(a => a.owner === 'guild')!.leader)
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy);
const plans: IxIndependentSkillsStep[] = [
{ actor: 'ixians', action: { type: 'battlePlan', dial: advanced ? 1.5 : 2.5, support: 0,
leader: trainer, weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } },
{ actor: 'guild', action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } },
];
const returns: HarassWithdrawSelection = { [first]: { normal: 1, elite: 0 }, [second]: { normal: 0, elite: 1 } };
assertIxIndependentSkillsCustody(game);
return { initial, beforeIxCard, ixCard, offered, afterSetup, beforeShipment, shipment, afterShipment,
move, afterMove, opponentMove, beforeBattle, battleStep, game, plans, returns,
ixians: 'ixians', tleilaxu: 'tleilaxu', opponent: 'guild', trainer, first, second, actions, staging };
}
export function revealIxIndependentSkillsBattle(f: IxIndependentSkillsFixture, state = f.game): Game {
let game = structuredClone(state);
for (const step of f.plans) game = stepIxIndependentSkills(game, step);
assert.ok(game.battle?.revealed); return game;
}
export function quoteIxIndependentSkillsBattle(f: IxIndependentSkillsFixture, state = revealIxIndependentSkillsBattle(f)) {
return quoteIndependentSkillsBattle(state, { returns: f.returns });
}
export function finishIxIndependentSkillsBattle(f: IxIndependentSkillsFixture,
options: { state?: Game; rescue?: 'save' | 'none'; stop?: 'rescue' | 'collection' } = {}): Game {
let game = structuredClone(options.state ?? revealIxIndependentSkillsBattle(f));
for (let i = 0; i < 400; i++) {
if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game;
if (game.phase === 7 && ixIndependentSkillsClean(game)) return game;
let step: IxIndependentSkillsStep | null;
const d = game.decision;
if (d?.kind === 'harassWithdraw') step = { actor: d.player,
action: { type: 'decision', event: d.event, returns: f.returns } };
else if (d?.kind === 'sukRescue') {
const choice = options.rescue === 'none' ? d.options.findIndex(o => o.normal + o.elite === 0)
: d.options.findIndex(o => o.normal === 1 && o.elite === 1 && o.kept?.kind === 'elite');
assert.ok(choice >= 0, 'Choose the original Cyborg/Suboid rescue');
step = { actor: d.player, action: { type: 'decision', event: d.event, choice } };
} else step = nextIxIndependentSkillsStep(game);
assert.ok(step); game = stepIxIndependentSkills(game, step);
}
throw Error('Native independent battle did not reach Collection');
}
/** Real combat Tanks → real escrow bribe and Mentat → turn-two Revival. */
export function createIxIndependentSkillsRevivalFixture(options: IxIndependentSkillsOptions = {}) {
const f = createIxIndependentSkillsFixture(options);
const afterBattle = finishIxIndependentSkillsBattle(f, { rescue: 'none' });
const bribe: IxIndependentSkillsStep = { actor: f.opponent, action: { type: 'bribe', target: f.ixians, amount: 3 } };
let game = stepIxIndependentSkills(afterBattle, bribe);
game = advanceIxIndependentSkills(game, g => g.turn === 2 && g.phase === 4 && ixIndependentSkillsClean(g) && !g.pendingRevival &&
(!g.revivalOrder || g.revivalOrder.remaining[0] === f.ixians));
const staging = [...f.staging]; conserveCard(game, f.ixians, RECRUITS_CARD_ID, staging);
const beforeRecruits = structuredClone(game);
const priorReturn: IxIndependentSkillsStep = { actor: f.ixians, action: { type: 'revive', amount: 1, elite: 0 } };
game = stepIxIndependentSkills(game, priorReturn);
game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival);
const beforePlay = structuredClone(game);
const play: IxIndependentSkillsStep = { actor: f.ixians, action: { type: 'card', card: RECRUITS_CARD_ID } };
game = stepIxIndependentSkills(game, play);
game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival);
const beforeRevival = structuredClone(game);
const revive: IxIndependentSkillsStep = { actor: f.ixians,
action: { type: 'revive', amount: 3, elite: 1, freeElite: 0 } };
const price = forceRevivalQuote(game, ixIndependentSkillsPlayer(game, f.ixians), 3, 1, 0);
game = stepIxIndependentSkills(game, revive);
game = advanceIxIndependentSkills(game, g => ixIndependentSkillsClean(g) && !g.pendingRevival);
return { ...f, afterBattle, bribe, beforeRecruits, priorReturn, beforePlay, play,
beforeRevival, revive, price, afterRevival: game, staging };
}