Compose Advanced native Ixian and CHOAM Leader Skills

This commit is contained in:
Quintine committed 2026-10-03 13:06:44 +10:00
1 parent 64c8dfc770
commit 7ee064df14
20 files changed
+723 -71

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
advancedNativePlayer as player, advancedNativeStep, advanceAdvancedNativeSkillsToPhase,
assertAdvancedNativeCustody as custody, completeAdvancedNativeSkillsSetup,
completedAdvancedNativeSkillsGame, createAdvancedNativeSkillBattle,
finishAdvancedNativeSkillAftermath, initializeAdvancedNativeSkillsSetup,
openAdvancedNativeSkillBattle, rejectAdvancedNativeAction as reject,
} from './fixture-advanced-native-skills';
function trainer(game: Game, actor: string): string {
const assignment = game.leaderSkills!.assignments.find(a => a.owner === actor);
assert.ok(assignment);
return assignment.leader;
}
function untrainedLeader(game: Game, actor: string): string {
const p = game.players.find(p => p.id === actor)!;
const leaders = p.leaders.filter(l => !l.dead && l.id !== trainer(game, actor) && l.id !== 'choam-auditor');
leaders.sort((a, b) => a.strength - b.strength);
assert.ok(leaders[0]);
return leaders[0].id;
}
function resolvePlans(game: Game, actor: string, target: string): Game {
game = applyAction(game, actor, { type: 'traitorCall', call: false });
return applyAction(game, target, { type: 'traitorCall', call: false });
}
void test('Advanced native Ix typed support preserves paid cyborg casualties through skilled Suk and native substitution', () => {
let game = createAdvancedNativeSkillBattle({ family: 'ixians', requestedSkill: 'suk-graduate' });
const actor = player(game, 'ixians').id;
const target = player(game, 'emperor').id;
const funds = player(game, 'ixians').spice;
game = openAdvancedNativeSkillBattle(game, actor, target);
reject(game, actor, { type: 'battlePlan', dial: 5, support: 3, leader: trainer(game, actor) });
game = applyAction(game, actor, { type: 'battlePlan', dial: 5, support: 1, leader: trainer(game, actor) });
game = applyAction(game, target, { type: 'battlePlan', dial: 0, support: 0, leader: untrainedLeader(game, target) });
game = resolvePlans(game, actor, target);
if (game.decision?.kind === 'battleLosses') {
const choice = game.decision.options.findIndex(o => o.normal === 4 && o.elite === 2);
assert.ok(choice >= 0);
game = applyAction(game, actor, { type: 'decision', choice });
}
assert.equal(player(game, 'ixians').spice, funds - 1);
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw new Error('Expected native Suk rescue');
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: 1 });
const pending = game;
const choice = game.decision.options.findIndex(o => o.normal === 1 && o.elite === 1 && o.kept?.kind === 'normal');
assert.ok(choice >= 0);
reject(game, target, { type: 'decision', event: game.decision.event, choice });
game = applyAction(game, actor, { type: 'decision', event: game.decision.event, choice });
assert.equal(player(game, 'ixians').forces['wind_pass:14'], 3);
assert.equal(player(game, 'ixians').reserves, 13);
assert.equal(player(game, 'ixians').tanks, 4);
assert.equal(player(game, 'ixians').elites!.reserves, 6);
assert.equal(player(game, 'ixians').elites!.tanks, 1);
assert.equal(game.decision?.kind, 'ixSubstitution');
reject(game, actor, { type: 'decision', sources: { 'wind_pass:14': 2 }, recover: { 'wind_pass:14': 2 } });
game = applyAction(game, actor, { type: 'decision', sources: { 'wind_pass:14': 1 }, recover: { 'wind_pass:14': 1 } });
game = finishAdvancedNativeSkillAftermath(game);
assert.equal(player(game, 'ixians').elites!.tanks, 0);
assert.equal(player(game, 'ixians').elites!.forces['wind_pass:14'], 1);
assert.equal(player(game, 'ixians').tanks, 4);
assert.equal(player(game, 'ixians').spice, funds - 1);
custody(game);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, difficulty);
const rescued = applyAction(pending, actor, action);
assert.equal(rescued.pendingSukRescue, null);
assert.equal(player(rescued, 'ixians').spice, funds - 1);
custody(rescued);
}
});
void test('Advanced CHOAM cannot train its original Auditor; a real native trainer still takes the physical skill', () => {
const options = { family: 'choam' as const, requestedSkill: 'bureaucrat' as const };
let game = initializeAdvancedNativeSkillsSetup(options);
const actor = player(game, 'choam').id;
const offer = game.leaderSkills!.offers[actor];
reject(game, actor, { type: 'leaderSkill', event: offer.event, skill: 'bureaucrat', leader: 'choam-auditor' });
const native = viewGame(game, actor).leaderSkills!.eligibleLeaders[0];
assert.ok(native);
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill: 'bureaucrat', leader: native.id });
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === actor)!.leader, native.id);
game = completeAdvancedNativeSkillsSetup(game, options);
const auditor = player(game, 'choam').leaders.find(l => l.id === 'choam-auditor')!;
assert.ok(auditor && !auditor.dead);
custody(game);
});
void test('original Auditor paid revival returns that leader without drawing or replacing a skill', () => {
let game = completedAdvancedNativeSkillsGame({ family: 'choam', requestedSkill: 'bureaucrat' });
game = advanceAdvancedNativeSkillsToPhase(game, 4);
const owner = player(game, 'choam');
const auditor = owner.leaders.find(l => l.id === 'choam-auditor')!;
assert.ok(auditor && !auditor.dead);
// Controlled death position, preserving the original Auditor and trained leader.
auditor.dead = true;
auditor.deaths = 1;
const funds = owner.spice;
const skills = structuredClone(game.leaderSkills);
const revival = viewGame(game, owner.id).revival.leaders.find(l => l.id === auditor.id);
assert.ok(revival);
assert.equal(revival.cost, 2);
game = applyAction(game, owner.id, { type: 'reviveLeader', leader: auditor.id });
for (let i = 0; (game.response || game.decision || game.pendingRevival) && i < 100; i++) game = advancedNativeStep(game);
assert.equal(player(game, 'choam').leaders.find(l => l.id === auditor.id)!.dead, false);
assert.equal(player(game, 'choam').spice, funds - 2);
assert.equal(player(game, 'choam').leaderRevived, true);
assert.deepEqual(game.leaderSkills, skills);
reject(game, owner.id, { type: 'reviveLeader', leader: auditor.id });
custody(game);
});
void test('Advanced CHOAM own paid support does not generate its own income and Smuggler collects actual battlefield spice', () => {
let game = createAdvancedNativeSkillBattle({ family: 'choam', requestedSkill: 'smuggler' });
const actor = player(game, 'choam').id;
const target = player(game, 'emperor').id;
const ownFunds = player(game, 'choam').spice;
const enemyFunds = player(game, 'emperor').spice;
const collected = Math.min(5, player(game, 'choam').leaders.find(l => l.id === trainer(game, actor))!.strength);
game.spice['wind_pass:14'] = 5; // Explicit controlled battlefield-spice rule position.
game = openAdvancedNativeSkillBattle(game, actor, target);
reject(game, actor, { type: 'battlePlan', dial: 3, support: ownFunds + 1, leader: trainer(game, actor) });
game = applyAction(game, actor, { type: 'battlePlan', dial: 3, support: 2, leader: trainer(game, actor) });
game = applyAction(game, target, { type: 'battlePlan', dial: 2, support: 2, leader: untrainedLeader(game, target) });
game = resolvePlans(game, actor, target);
assert.equal(game.lastBattleContext?.smugglerCollection?.amount, collected);
assert.equal(game.lastBattleContext?.smugglerCollection?.stage, 'collected');
assert.equal(player(game, 'choam').spice, ownFunds - 2 + 1 + collected);
assert.equal(game.spice['wind_pass:14'], 5 - collected);
game = finishAdvancedNativeSkillAftermath(game);
assert.equal(player(game, 'choam').tanks, 4);
assert.equal(player(game, 'choam').forces['wind_pass:14'], 4);
assert.equal(player(game, 'emperor').tanks, 8);
assert.equal(player(game, 'emperor').spice, enemyFunds - 2);
assert.equal(player(game, 'choam').spice, ownFunds - 2 + 1 + 5,
'Original Collection collects the one spice left after the skilled leader, separately from support income.');
assert.equal(game.spice['wind_pass:14'] ?? 0, 0);
assert.equal(game.pendingChoamBattleIncome, null);
assert.equal(game.battle, null);
custody(game);
});
void test('CHOAM prepaid ally support consumes its original donor pool and excludes that share from native income', () => {
let game = createAdvancedNativeSkillBattle({ family: 'choam', skillOwner: 'atreides', requestedSkill: 'smuggler' });
const owner = player(game, 'atreides');
const donor = player(game, 'choam');
const enemy = player(game, 'emperor');
// Controlled mutual-alliance rule position; real native wallets fund the pledge.
owner.ally = donor.id;
donor.ally = owner.id;
const ownFunds = owner.spice;
const donorFunds = donor.spice;
const enemyFunds = enemy.spice;
game = applyAction(game, owner.id, { type: 'chooseBattle', territory: 'wind_pass', target: enemy.id });
while (game.decision?.kind === 'leaderSkillVisibility')
game = applyAction(game, game.decision.player,
{ type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
assert.equal(game.decision?.kind, 'choamBattleFunding');
reject(game, owner.id, { type: 'decision', amount: 2 });
game = applyAction(game, donor.id, { type: 'decision', amount: 2 });
assert.equal(player(game, 'choam').spice, donorFunds - 2);
assert.equal(game.aid[donor.id].amount, 2);
for (let i = 0; (game.response || game.decision || game.battle?.preparation) && i < 100; i++) {
if (game.decision?.kind === 'leaderSkillVisibility')
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
else game = advancedNativeStep(game);
}
reject(game, owner.id, { type: 'battlePlan', dial: 4, support: 4, allyPayment: 3, leader: trainer(game, owner.id) });
game = applyAction(game, owner.id, { type: 'battlePlan', dial: 4, support: 4, allyPayment: 2, leader: trainer(game, owner.id) });
game = applyAction(game, enemy.id, { type: 'battlePlan', dial: 2, support: 2, leader: untrainedLeader(game, enemy.id) });
game = finishAdvancedNativeSkillAftermath(resolvePlans(game, owner.id, enemy.id));
assert.equal(player(game, 'atreides').spice, ownFunds - 2);
assert.equal(player(game, 'choam').spice, donorFunds - 2 + 2,
'Income is floor((4 supported - 2 prepaid)/2) plus floor(2 opponent support/2).');
assert.equal(player(game, 'emperor').spice, enemyFunds - 2);
assert.equal(game.aid[donor.id]?.amount ?? 0, 0);
assert.equal(player(game, 'atreides').forces['wind_pass:14'], 4);
assert.equal(player(game, 'atreides').tanks, 4);
assert.equal(player(game, 'emperor').tanks, 8);
assert.equal(game.battle, null);
custody(game);
});
void test('native CHOAM Bureaucrat redirects only another payer while its native Worthless power remains usable', () => {
let game = createAdvancedNativeSkillBattle({ family: 'choam', requestedSkill: 'bureaucrat' });
Object.assign(game, { phase: 5, active: player(game, 'choam').id });
const owner = player(game, 'choam');
const payer = player(game, 'emperor');
const payee = player(game, 'atreides');
const funds = payer.spice;
const bribes = payee.bribes;
game = applyAction(game, payer.id, { type: 'bribe', target: payee.id, amount: 5 });
assert.equal(game.decision?.kind, 'bureaucratPayment');
assert.equal(game.decision?.player, owner.id);
const event = game.bureaucratPaymentEvent;
game = applyAction(game, owner.id, { type: 'decision', event, redirect: true });
assert.equal(player(game, 'emperor').spice, funds - 5);
assert.equal(player(game, 'atreides').bribes, bribes + 3);
reject(game, owner.id, { type: 'decision', event, redirect: true });
const index = game.deck.findIndex(c => c.name === 'Kulon');
assert.ok(index >= 0);
const kulon = game.deck.splice(index, 1)[0];
player(game, 'choam').hand.push(kulon); // Conserved canonical native card staging.
game = applyAction(game, owner.id, { type: 'card', mode: 'choam', card: kulon.id });
for (let i = 0; (game.response || game.pendingTreacheryDiscard) && i < 100; i++) game = advancedNativeStep(game);
assert.equal(game.choamMovement?.bonus, 1);
assert.equal(player(game, 'choam').hand.some(c => c.id === kulon.id), false);
assert.equal(game.discard.filter(c => c.id === kulon.id).length, 1);
custody(game);
});
void test('Advanced native admission preserves Atreides Suk and foreign-ghola unresolved boundaries without mutation', () => {
const options = { family: 'ixians' as const, skillOwner: 'atreides' as const, requestedSkill: 'suk-graduate' as const };
let game = initializeAdvancedNativeSkillsSetup(options);
while (game.decision?.kind === 'ixSetup') game = advancedNativeStep(game);
const actor = player(game, 'atreides').id;
const offer = game.leaderSkills!.offers[actor];
const leader = viewGame(game, actor).leaderSkills!.eligibleLeaders[0].id;
reject(game, actor, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader }, /Suk Graduate is unavailable/);
game = completeAdvancedNativeSkillsSetup(game, options);
assert.ok(game.leaderSkills!.deck.includes('suk-graduate'));
assert.notEqual(game.leaderSkills!.assignments.find(a => a.owner === actor)!.skill, 'suk-graduate');
custody(game);
// Controlled original foreign-ghola custody boundary: use an untrained foreign
// leader, not a fabricated skill assignment or replacement deck.
const foreign = player(game, 'emperor').leaders.find(l => l.id !== trainer(game, player(game, 'emperor').id))!;
foreign.gholaBy = player(game, 'ixians').id;
reject(game, actor, { type: 'ready' }, /foreign gholas/);
});
void test('captured native trainer blocks replacement revival, retaining the physical skill and Tanks leader', () => {
let game = completedAdvancedNativeSkillsGame({ family: 'ixians', requestedSkill: 'warmaster', opponents: ['harkonnen', 'emperor'] });
game = advanceAdvancedNativeSkillsToPhase(game, 4);
const owner = player(game, 'ixians');
const trained = owner.leaders.find(l => l.id === trainer(game, owner.id))!;
trained.capturedBy = player(game, 'harkonnen').id;
const dead = owner.leaders.find(l => l.id !== trained.id)!;
for (const leader of owner.leaders) if (leader.id !== trained.id) {
leader.dead = true;
leader.deaths = 1;
} // Controlled captured trainer and all other native leaders in Tanks.
const skills = structuredClone(game.leaderSkills);
reject(game, owner.id, { type: 'reviveLeader', leader: dead.id }, /replacement-skill ruling/);
assert.equal(dead.dead, true);
assert.equal(trained.capturedBy, player(game, 'harkonnen').id);
assert.deepEqual(game.leaderSkills, skills);
custody(game);
});
+27 -15
View File
@@ -73,6 +73,7 @@ function assertFailedEvidence(
name: string,
expectedSeed: number,
resumed: boolean,
expectedCount = 1,
) {
const report = json<CliReport>(join(out, 'report.json'));
const results = json<{ results: CliResult[] }>(join(out, 'results.json'));
@@ -82,9 +83,10 @@ function assertFailedEvidence(
assert.equal(report.before.tree, report.after.tree);
assert.match(report.before.commit, /\S/);
assert.match(report.before.tree, /^[a-f\d]{64}$/);
assert.equal(report.results.length, 1);
assert.equal(results.results.length, 1);
for (const result of [report.results[0], results.results[0]]) {
assert.equal(report.results.length, expectedCount);
assert.equal(results.results.length, expectedCount);
for (const result of [report.results.find(row => row.name === name), results.results.find(row => row.name === name)]) {
assert.ok(result);
assert.equal(result.name, name);
assert.equal(result.seed, expectedSeed);
assert.equal(result.resumed, resumed);
@@ -280,10 +282,10 @@ void test('Tleilaxu Skills samples preserve their full module on saved continuat
assert.equal(existsSync(unsupported), false);
});
void test('Ixian Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
void test('Ixian Skills samples preserve full Basic and Advanced native decks through saved continuation', (t) => {
const area = temporary(t);
const out = join(area, 'skills');
const result = run(out, '--profile', 'ix-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
const result = run(out, '--profile', 'ix-skills', '--rules', 'basic', '--players', '2', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'ix-skills-2-basic', 1026, false);
const snapshot = join(out, 'failed-ix-skills-2-basic.json');
@@ -294,16 +296,22 @@ void test('Ixian Skills samples preserve their full module on saved continuation
const resumed = join(area, 'skills-resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(resumed, 'ix-skills-2-basic', 1026, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'ix-skills', '--rules', 'advanced');
assert.equal(bad.status, 1);
assert.equal(existsSync(unsupported), false);
const advanced = join(area, 'advanced');
assert.equal(run(advanced, '--profile', 'ix-skills', '--rules', 'advanced', '--players', '2', '--seed', '1000', '--max-actions', '1').status, 1);
for (const [name, seed] of [['ix-skills-2-advanced', 1220], ['ix-skills-ixians-choam-2-advanced', 1230]] as const) {
assertFailedEvidence(advanced, name, seed, false, 2);
const state = json<typeof game>(join(advanced, `failed-${name}.json`));
assert.equal(state.advanced, true);
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
assert.equal(new Set(cards).size, 14);
assert.equal(cards.length, 14);
}
});
void test('CHOAM Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
void test('CHOAM Skills samples preserve full Basic and Advanced native decks through saved continuation', (t) => {
const area = temporary(t);
const out = join(area, 'skills');
const result = run(out, '--profile', 'choam-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
const result = run(out, '--profile', 'choam-skills', '--rules', 'basic', '--players', '2', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'choam-skills-2-basic', 1031, false);
const snapshot = join(out, 'failed-choam-skills-2-basic.json');
@@ -314,10 +322,14 @@ void test('CHOAM Skills samples preserve their full module on saved continuation
const resumed = join(area, 'skills-resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(resumed, 'choam-skills-2-basic', 1031, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'choam-skills', '--rules', 'advanced');
assert.equal(bad.status, 1);
assert.equal(existsSync(unsupported), false);
const advanced = join(area, 'advanced');
assert.equal(run(advanced, '--profile', 'choam-skills', '--rules', 'advanced', '--players', '2', '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(advanced, 'choam-skills-2-advanced', 1225, false);
const state = json<typeof game>(join(advanced, 'failed-choam-skills-2-advanced.json'));
assert.equal(state.advanced, true);
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
assert.equal(new Set(cards).size, 14);
assert.equal(cards.length, 14);
});
void test('Homeworld and Nexus sample snapshots resume only their matching module and roster', (t) => {
const area = temporary(t);
+198
View File
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type AdvancedNativeSkillsOptions = {
family: 'ixians' | 'choam';
requestedSkill?: LeaderSkillId;
skillOwner?: FactionId;
opponents?: readonly FactionId[];
bankerIncome?: boolean;
/** Continue this actual authenticated lobby/setup without replacing its seats. */
initial?: Game;
};
export function advancedNativePlayer(game: Game, faction: FactionId): Player {
const player = game.players.find(p => p.faction === faction);
assert.ok(player, `Missing actual ${faction} seat`);
return player;
}
/** Only the first physical all14 shuffle is controlled. Later native shuffles
* use real entropy. Place the requested skill in its actual owner's offer. */
function withSkillShuffle<T>(skill: LeaderSkillId, ownerIndex: number, initialize: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
const target = LEADER_SKILL_CARDS.findIndex(card => card.id === skill);
const slot = ownerIndex * 2;
assert.ok(target >= 0 && slot < LEADER_SKILL_CARDS.length);
let index = LEADER_SKILL_CARDS.length - 1;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (index <= 0) return Reflect.apply(original, crypto, [array]) as V;
assert.ok(array instanceof Uint32Array && array.length === 1);
const swap = index === Math.max(target, slot) ? Math.min(target, slot) : index;
array[0] = swap === index ? 0xffffffff : Math.ceil(swap * 0x100000000 / (index + 1));
index--;
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Genuine fresh Advanced native lobby, native starting-card decisions and
* physical skill offers. No played save is converted and no deck is cherry-picked. */
export function initializeAdvancedNativeSkillsSetup(options: AdvancedNativeSkillsOptions): Game {
const ownerFaction = options.skillOwner ?? options.family;
const expansion = options.family === 'ixians' ? 'ix' : 'choam';
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions = [ownerFaction, options.family, ...(options.opponents ?? ['atreides', 'emperor'] as FactionId[])]
.filter((faction, index, all) => all.indexOf(faction) === index);
game = createGame('ADVNATIVE', newPlayer(factions[0], factions[0], factions[0]), true, [expansion]);
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
}
assert.equal(game.advanced, true);
assert.ok(game.expansions.includes(expansion));
const owner = advancedNativePlayer(game, ownerFaction);
if (game.status === 'setup') {
assert.ok(game.leaderSkills && game.turn === 1 && game.phase === 0,
'Only the original fresh native skill setup may continue.');
return game;
}
assert.equal(game.status, 'lobby');
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return withSkillShuffle(options.requestedSkill ?? 'suk-graduate', game.players.findIndex(p => p.id === owner.id),
() => options.bankerIncome ? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game));
}
/** Finish original setup decisions from the actual own-seat projections. */
export function completeAdvancedNativeSkillsSetup(state: Game, options: AdvancedNativeSkillsOptions): Game {
let game = state;
const owner = advancedNativePlayer(game, options.skillOwner ?? options.family).id;
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.decision?.kind === 'ixSetup') {
const actor = game.decision.player;
const card = viewGame(game, actor).ixTechnology!.setup![0];
assert.ok(card);
game = applyAction(game, actor, { type: 'decision', card: card.id });
} else 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 requested = options.requestedSkill ?? 'suk-graduate';
const skill = actor === owner && offer.cards.includes(requested) && !view.unavailableSkills?.[requested]
? requested : offer.cards.find(card => !view.unavailableSkills?.[card]);
assert.ok(skill && view.eligibleLeaders[0]);
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: view.eligibleLeaders[0].id });
} else game = advancedNativeStep(game);
}
assert.equal(game.status, 'playing');
return game;
}
export function completedAdvancedNativeSkillsGame(options: AdvancedNativeSkillsOptions): Game {
return completeAdvancedNativeSkillsSetup(initializeAdvancedNativeSkillsSetup(options), options);
}
export function advancedNativeStep(game: Game): Game {
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return applyAction(game, next.actor, next.action);
}
export function advanceAdvancedNativeSkillsToPhase(state: Game, target: number): Game {
let game = state;
for (let i = 0; (game.phase !== target || game.phaseOpening || game.response || game.decision)
&& game.turn === state.turn && game.status === 'playing' && i < 1000; i++) game = advancedNativeStep(game);
assert.equal(game.turn, state.turn);
assert.equal(game.phase, target);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
return game;
}
export function rejectAdvancedNativeAction(game: Game, actor: string, action: Action, pattern?: RegExp): void {
const before = structuredClone(game);
if (pattern) assert.throws(() => applyAction(game, actor, action), pattern);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
export function assertAdvancedNativeCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'ixians' ? 7 : 5);
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
}
}
}
/** Explicit controlled rule-unit position AFTER genuine setup. Return every
* deployed piece to its own reserves, then transfer only the selected forces;
* preserve real wallets, leaders, traits and every physical skill/treachery card.
* This is not claimed to be a played turn history. */
export function createAdvancedNativeSkillBattle(options: AdvancedNativeSkillsOptions): Game {
const game = completedAdvancedNativeSkillsGame(options);
const owner = advancedNativePlayer(game, options.skillOwner ?? options.family);
const enemy = advancedNativePlayer(game, 'emperor');
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 = {};
}
}
for (const p of [owner, enemy]) {
assert.ok(p.reserves >= 8);
p.reserves -= 8;
p.forces['wind_pass:14'] = 8;
}
if (owner.faction === 'ixians') {
owner.elites!.reserves -= 2;
owner.elites!.forces['wind_pass:14'] = 2;
}
Object.assign(game, { phase: 6, active: owner.id, order: [owner.id, enemy.id, ...game.players.filter(p => p !== owner && p !== enemy).map(p => p.id)],
storm: 18, ready: [], decision: null, response: null, phaseOpening: null });
assertAdvancedNativeCustody(game);
return game;
}
export function openAdvancedNativeSkillBattle(state: Game, actor: string, target: string): Game {
let game = applyAction(state, actor, { type: 'chooseBattle', territory: 'wind_pass', target });
for (let i = 0; (game.response || game.decision || game.battle?.preparation) && i < 100; i++) {
if (game.decision?.kind === 'leaderSkillVisibility') {
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
} else game = advancedNativeStep(game);
}
assert.equal(game.decision, null);
assert.equal(game.response, null);
return game;
}
/** Resolve only native response/cleanup actions, stopping at optional Suk rescue. */
export function finishAdvancedNativeSkillAftermath(state: Game): Game {
let game = state;
for (let i = 0; (game.response || game.decision || game.battle || game.pendingTreacheryDiscard) && i < 100; i++) {
if (game.decision?.kind === 'sukRescue') break;
if (game.decision?.kind === 'ixSubstitution') {
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
} else if (game.decision?.kind === 'battleCards') {
game = applyAction(game, game.decision.player, { type: 'decision', discard: [] });
} else game = advancedNativeStep(game);
}
return game;
}