264 lines
15 KiB
TypeScript
264 lines
15 KiB
TypeScript
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 });
|
|
if (game.decision?.kind === 'mentatQuestion')
|
|
game = applyAction(game, game.decision.player, {
|
|
type: 'decision', event: game.decision.event, decline: true,
|
|
});
|
|
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 assigns Atreides Suk and rejects a forged Ix-controlled foreign ghola 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;
|
|
// User ruling 7 October 2026: Suk is no longer withheld from Advanced Atreides.
|
|
const assigned = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader });
|
|
assert.equal(assigned.leaderSkills!.assignments.find(a => a.owner === actor)!.skill, 'suk-graduate');
|
|
assert.ok(!assigned.leaderSkills!.deck.includes('suk-graduate'));
|
|
game = completeAdvancedNativeSkillsSetup(assigned, options);
|
|
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === actor)!.skill, 'suk-graduate');
|
|
custody(game);
|
|
// An Ixian cannot control this original foreign disc. Legitimate native
|
|
// Tleilaxu revival is covered separately, without fabricating assignments.
|
|
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' });
|
|
});
|
|
|
|
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);
|
|
});
|