Compose Advanced native Ixian and CHOAM Leader Skills
This commit is contained in:
1 parent
64c8dfc770
commit
7ee064df14
20 files changed
+723
-71
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user