Files
Dune/tests/fixture-ix-choam-skills-tech.ts
Quintine b9330d489e Compose independent Skills with original Tech and Stronghold modules
Unify fresh/live classic and native phase admission, retaining original counts, earned custody, separate income ledgers and physical card/skill continuations. Plain paired E3 phases no longer require cards or Nexus. Coupled44-file471/471, types/lint/build, genuine programmes and390px normal entry pass. Controlled card inputs explicit; no save retrofit or deployment.
2026-10-17 22:12:44 +11:00

281 lines
15 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { createTechTokens } from '../game/tech-tokens';
import { nextSkillsTechBattleStep, openSkillsTechBattle } from './fixture-skills-tech-battle';
import { advanceSkillsTechPaymentsToPhase, skillsTechPaymentsEmptyDestination } from './fixture-skills-tech-payments';
export type IxChoamSkillsTechOptions = {
/** Fresh authenticated undealt lobby. Seat IDs, order and native offers survive. */
initial?: Game;
family?: 'ixians' | 'choam';
advanced?: boolean;
kind?: 'shipment' | 'suk-battle' | 'charity';
band?: 'normal' | 'skilled';
amount?: number;
elite?: number;
smuggler?: boolean;
};
export type IxChoamSkillsTechStep = { actor: string; action: Action };
export type IxChoamSkillsTechFixture = {
setup: Game;
game: Game;
actions: IxChoamSkillsTechStep[];
family: 'ixians' | 'choam';
kind: 'shipment' | 'suk-battle' | 'charity';
actor: string;
owner: string;
opponent: string;
banker?: string;
trainer: string;
action: Action;
opponentPlan?: Action;
beforeCharityIncome?: Game;
afterCharityIncome?: Game;
key?: string;
cost: number;
amount: number;
elite: number;
band: 'normal' | 'skilled';
};
export function ixChoamSkillsTechPlayer(game: Game, id: string): Player {
const player = game.players.find(p => p.id === id);
assert.ok(player);
return player;
}
/** Control only the first all14 shuffle. Native offers are dealt in retained seat
* order; subsequent Traitor, treachery and Tech randomness remains original. */
function withOffers<T>(selected: LeaderSkillId[], initialize: () => T): T {
const cards: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
const remaining = cards.filter(card => !selected.includes(card));
const desired = selected.flatMap(skill => [skill, remaining.shift()!]);
desired.push(...remaining);
const working = [...cards];
const rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor(((j + 0.5) / (i + 1)) * 0x100000000));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (index < rolls.length) {
assert.ok(array instanceof Uint32Array && array.length === 1);
array[0] = rolls[index++];
} else Reflect.apply(original, crypto, [array]);
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Native continuation only; decline optional substitution so it cannot conceal
* Suk's reserve/Tanks result. Human snapshots stop before this policy runs. */
export function nextIxChoamSkillsTechStep(game: Game): IxChoamSkillsTechStep {
if (game.decision?.kind === 'ixSubstitution')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
return nextSkillsTechBattleStep(game);
}
function step(game: Game, actions?: IxChoamSkillsTechStep[]): Game {
const next = nextIxChoamSkillsTechStep(game);
actions?.push(next);
return applyAction(game, next.actor, next.action);
}
function act(game: Game, actor: string, action: Action, actions: IxChoamSkillsTechStep[]): Game {
actions.push({ actor, action });
return applyAction(game, actor, action);
}
export function initializeIxChoamSkillsTechSetup(options: IxChoamSkillsTechOptions = {}): Game {
const family = options.family ?? 'ixians';
const expansion = family === 'ixians' ? 'ix' : 'choam';
let game = options.initial ? structuredClone(options.initial)
: createGame('IXCHOAMSKILLTECH', newPlayer('native', family, family), options.advanced ?? false, [expansion]);
if (!options.initial) {
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer('fremen', 'Fremen', 'fremen'));
joinGame(game, newPlayer('banker', 'Banker', 'atreides'));
}
assert.equal(game.status, 'lobby', 'Only the original fresh undealt lobby is accepted.');
assert.ok(game.expansions.includes(expansion));
assert.ok(game.players.length >= 3 && game.players.length <= 6);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(game.players.some(p => p.faction === family));
assert.ok(game.players.some(p => p.faction === 'emperor'));
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
assert.deepEqual(game.techTokens, createTechTokens());
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
const passive: LeaderSkillId[] = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'planetologist'];
const selected = game.players.map(p => p.faction === family
? options.kind === 'suk-battle' ? 'suk-graduate' : 'smuggler'
: p.faction === 'atreides' ? 'spice-banker' : passive.shift()!);
return withOffers(selected, () => initializeLeaderSkillsGameForAudit(game));
}
export function completeIxChoamSkillsTechSetup(state: Game, options: IxChoamSkillsTechOptions = {}, actions?: IxChoamSkillsTechStep[]): Game {
let game = state;
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const faction = ixChoamSkillsTechPlayer(game, actor).faction;
const skill = faction === (options.family ?? 'ixians')
? options.kind === 'suk-battle' ? 'suk-graduate' : 'smuggler'
: faction === 'atreides' ? 'spice-banker' : offer.cards[0];
assert.ok(offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = ixChoamSkillsTechPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader && leader.id !== 'choam-auditor');
const action: Action = { type: 'leaderSkill', event: offer.event, skill, leader: leader.id };
actions?.push({ actor, action });
game = applyAction(game, actor, action);
} else game = step(game, actions);
}
assert.equal(game.status, 'playing');
return game;
}
/** Original setup, first Storm and phase history. Only unplayed Spice Card
* order and (for battle) conserved board/hand custody are controlled. No wallet,
* token owner, phase, turn, plan or earned receipt is assigned. */
export function createIxChoamSkillsTechFixture(options: IxChoamSkillsTechOptions = {}): IxChoamSkillsTechFixture {
const family = options.family ?? 'ixians';
const kind = options.kind ?? 'shipment';
assert.ok(kind !== 'charity' || family === 'choam');
const band = options.band ?? 'skilled';
const actions: IxChoamSkillsTechStep[] = [];
const setup = initializeIxChoamSkillsTechSetup(options);
let game = completeIxChoamSkillsTechSetup(setup, options, actions);
const owner = game.players.find(p => p.faction === family)!.id;
const opponent = game.players.find(p => p.faction === 'emperor')!.id;
const banker = game.players.find(p => p.faction === 'atreides')?.id;
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
// Four conserved non-worm cards avoid optional riders during the two-turn
// charity continuation. They are not asserted to be a natural shuffle history.
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 4);
for (let i = 0; i < blows.length; i++) {
const index = game.spiceDeck.indexOf(blows[i]);
game.spiceDeck.splice(i, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = advanceSkillsTechPaymentsToPhase(game, 5, actions);
if (family === 'ixians') assert.equal(game.techTokens!.heighliners.owner, owner);
if (kind === 'suk-battle') {
// Explicit physical battle rule position: return board counters/cards to
// native reserves/deck, then place eight counters (two Ix cyborgs).
for (const p of game.players) {
game.deck.push(...p.hand); p.hand = [];
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
p.elites.forces = {};
}
if (p.advisors) p.advisors = {};
}
const territory = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm));
assert.ok(territory);
const key = `${territory.id}:${territory.sectors[0]}`;
for (const id of [owner, opponent]) {
const p = ixChoamSkillsTechPlayer(game, id);
assert.ok(p.reserves >= 8); p.reserves -= 8; p.forces[key] = 8;
}
if (family === 'ixians') {
const p = ixChoamSkillsTechPlayer(game, owner);
p.elites!.reserves -= 2; p.elites!.forces[key] = 2;
}
const shield = game.deck.findIndex(card => card.kind === 'shield');
assert.ok(shield >= 0);
const defense = game.deck.splice(shield, 1)[0];
ixChoamSkillsTechPlayer(game, owner).hand.push(defense);
game = advanceSkillsTechPaymentsToPhase(game, 6, actions);
game = openSkillsTechBattle(game, owner, opponent, territory.id, band === 'skilled');
const untrained = (id: string, strongest: boolean) => ixChoamSkillsTechPlayer(game, id).leaders
.filter(l => !l.dead && l.id !== 'choam-auditor' && !game.leaderSkills!.assignments.some(a => a.leader === l.id))
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0].id;
const dial = game.advanced && family === 'ixians' ? 5 : 4;
const support = game.advanced ? family === 'ixians' ? 1 : 4 : 0;
return { setup, game, actions, family, kind, actor: owner, owner, opponent, banker, trainer, key,
action: { type: 'battlePlan', leader: band === 'skilled' ? trainer : untrained(owner, true), dial, support, defense: defense.id },
opponentPlan: { type: 'battlePlan', leader: untrained(opponent, false), dial: 0, support: 0 },
cost: support, amount: 8, elite: family === 'ixians' ? 2 : 0, band };
}
const actor = kind === 'charity' ? opponent : owner;
for (let i = 0; game.active !== actor && i < 30; i++) game = step(game, actions);
assert.equal(game.active, actor);
const amount = kind === 'charity' ? 5 : options.amount ?? 5;
const elite = kind === 'charity' ? 0 : options.elite ?? (family === 'ixians' ? 2 : 0);
let target: { territory: string; sector: number };
if (kind === 'charity') {
const territory = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm)
&& game.players.every(p => Object.entries(p.forces).every(([key, n]) => !n || !key.startsWith(`${t.id}:`)))
&& t.sectors.every(s => !game.spice[`${t.id}:${s}`])
&& blows.every(card => !('territory' in card) || card.territory !== t.id));
assert.ok(territory); target = { territory: territory.id, sector: territory.sectors[0] };
game = act(game, actor, { type: 'ship', ...target, amount, elite }, actions);
for (let i = 0; (game.response || game.decision || game.pendingShipment) && i < 200; i++) game = step(game, actions);
// Actual ten-spice desert invoice exhausts Emperor's original bank wallet.
// Any native Heighliners payout can add at most one; no Collection at this pile.
const hand = ixChoamSkillsTechPlayer(game, actor).hand;
if (!hand.some(card => card.effect === 'karama')) {
// A real starting deal can put both printed counters in hands, not the deck.
const source = [game.deck, game.discard,
...game.players.filter(player => player.id !== actor).map(player => player.hand)]
.find(zone => zone.some(card => card.effect === 'karama'));
assert.ok(source, 'Conserve an original printed Karama from its actual physical zone');
const counter = source.findIndex(card => card.effect === 'karama');
hand.push(source.splice(counter, 1)[0]);
}
let beforeCharityIncome: Game | undefined;
let afterCharityIncome: Game | undefined;
for (let i = 0; (game.turn !== 2 || game.phase !== 2 || game.phaseOpening || game.response || game.decision)
&& game.status === 'playing' && i < 2000; i++) {
if (game.turn === 2 && game.response?.kind === 'choamCharity' && !beforeCharityIncome)
beforeCharityIncome = structuredClone(game);
game = step(game, actions);
if (beforeCharityIncome && !afterCharityIncome && game.response?.kind !== 'choamCharity')
afterCharityIncome = structuredClone(game);
}
assert.ok(beforeCharityIncome && afterCharityIncome);
assert.equal(game.turn, 2); assert.equal(game.phase, 2);
assert.ok(ixChoamSkillsTechPlayer(game, actor).spice < 2);
return { setup, game, actions, family, kind, actor, owner, opponent, banker, trainer,
action: { type: 'charity' }, cost: 10, amount: 2 - ixChoamSkillsTechPlayer(game, actor).spice,
elite: 0, band, key: `${target.territory}:${target.sector}`, beforeCharityIncome, afterCharityIncome };
}
target = skillsTechPaymentsEmptyDestination(game);
const smuggler = options.smuggler !== false;
return { setup, game, actions, family, kind, actor, owner, opponent, banker, trainer,
action: { type: 'ship', ...target, amount, elite, ...(smuggler ? { smuggler: true } : {}) },
cost: amount - (smuggler ? 1 : 0), amount, elite, band, key: `${target.territory}:${target.sector}` };
}
/** Settle native response/arrival windows, or stop at the real Suk choice. */
export function settleIxChoamSkillsTech(state: Game, stopAtSuk = false): Game {
let game = state;
for (let i = 0; i < 300; i++) {
if (stopAtSuk && (game.decision?.kind === 'sukRescue' ||
game.decision?.kind === 'battleCards' && game.lastBattleContext?.sukRescue?.completed)) return game;
if (!game.response && !game.decision && !game.pendingShipment && !game.battle && !game.pendingTreacheryDiscard) return game;
if (game.decision?.kind === 'battleLosses') {
const options = game.decision.options;
const choice = options.findIndex(o => o.elite === 2);
assert.ok(choice >= 0);
game = applyAction(game, game.decision.player, { type: 'decision', choice });
} else game = step(game);
}
throw new Error('Original Ix/CHOAM continuation did not settle.');
}