Files
Dune/tests/native-ecaz-skills.test.ts
T

315 lines
19 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { leaders, treacheryDeck, type Card } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import { newRevivalRules } from '../game/revival';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
advancedNativePlayer as player, advancedNativeStep, advanceAdvancedNativeSkillsToPhase,
assertAdvancedNativeCustody, completedAdvancedNativeSkillsGame,
initializeAdvancedNativeSkillsSetup, openAdvancedNativeSkillBattle,
stageAdvancedNativeSkillBattle, finishAdvancedNativeSkillAftermath,
} from './fixture-advanced-native-skills';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
const act = (game: Game, actor: string, action: Action): Game => applyAction(reload(game), actor, action);
const trainer = (game: Game, actor: string) => game.leaderSkills!.assignments.find(a => a.owner === actor)!;
const wallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
function custody(game: Game): void {
assertAdvancedNativeCustody(game);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
assert.deepEqual(game.players.flatMap(p => p.leaders.map(l => l.id)).sort(),
game.players.flatMap(p => leaders(p.faction).map(l => l.id)).sort());
const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices]),
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])];
assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort());
if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors);
}
function take(game: Game, actor: string, matches: (card: Card) => boolean): Card {
const index = game.deck.findIndex(matches);
assert.ok(index >= 0, 'Controlled card staging transfers a real remaining native card.');
const card = game.deck.splice(index, 1)[0];
game.players.find(p => p.id === actor)!.hand.push(card);
return card;
}
function enemyLeader(game: Game): string {
const enemy = player(game, 'emperor');
const selected = enemy.leaders.filter(l => !l.dead && l.id !== trainer(game, enemy.id).leader)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(selected);
return selected.id;
}
function resolve(game: Game, actor: string, target: string): Game {
return act(act(game, actor, { type: 'traitorCall', call: false }), target, { type: 'traitorCall', call: false });
}
function responses(state: Game): Game {
let game = state;
for (let i = 0; (game.response || game.pendingTreacheryDiscard) && i < 100; i++) game = advancedNativeStep(game);
assert.equal(game.response, null);
return game;
}
void test('native Basic and Advanced Ecaz deal original starting cards, public skills, loyalty and traitors before six-force placement', () => {
for (const rules of ['basic', 'advanced'] as const) {
let game = initializeAdvancedNativeSkillsSetup({ family: 'ecaz', requestedSkill: 'warmaster', rules });
const actor = player(game, 'ecaz').id;
assert.equal(game.setupStage, 'leaderSkills');
assert.deepEqual(game.players.map(p => p.hand.length), [1, 1, 1]);
assert.deepEqual(game.players.map(p => p.traitorChoices), [[], [], []]);
assert.equal(player(game, 'ecaz').reserves, 20);
const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards)];
assert.deepEqual(skills.sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
assert.deepEqual(viewGame(game, actor).leaderSkills!.eligibleLeaders.map(l => l.id).sort(),
leaders('ecaz').map(l => l.id).sort());
for (const seat of game.players) {
const own = viewGame(game, seat.id).leaderSkills!;
const offer = own.offer!;
const skill = seat.id === actor ? 'warmaster' : offer.cards.find(c => !own.unavailableSkills?.[c])!;
game = act(game, seat.id, { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id });
}
assert.equal(game.setupStage, 'traitors');
assert.equal(trainer(game, actor).skill, 'warmaster');
for (const seat of game.players) {
assert.equal(seat.traitorChoices.length, 4);
assert.equal(viewGame(game, seat.id).leaderSkills!.assignments.find(a => a.owner === actor)!.leader, trainer(game, actor).leader);
}
if (rules === 'advanced') {
const loyal = game.ecazLoyalty!.card!;
assert.ok(leaders('ecaz').some(l => l.id === loyal));
assert.equal([...game.traitorReserve!, ...game.players.flatMap(p => p.traitorChoices)].includes(loyal), false);
assert.equal(player(game, 'ecaz').leaders.find(l => l.id === loyal)!.dead, false);
for (const seat of game.players) assert.deepEqual(viewGame(game, seat.id).ecazLoyalty, { player: actor, card: loyal });
} else assert.equal(viewGame(game, actor).ecazLoyalty, null);
custody(game);
for (const seat of game.players) {
const choice = viewGame(game, seat.id).players.find(p => p.id === seat.id)!.traitorChoices![0];
assert.ok(choice);
game = act(game, seat.id, { type: 'traitor', leader: choice });
}
assert.equal(game.setupStage, 'forces');
assert.deepEqual(game.players.map(p => p.traitors.length), [1, 1, 1]);
assert.equal(player(game, 'ecaz').reserves, 20);
game = act(game, actor, { type: 'ecazSetup', placements: { 'imperial_basin:9': 2, 'imperial_basin:10': 4 } });
assert.equal(game.status, 'playing');
assert.equal(game.phase, 0);
assert.equal(player(game, 'ecaz').reserves, 14);
assert.deepEqual(player(game, 'ecaz').forces, { 'imperial_basin:9': 2, 'imperial_basin:10': 4 });
custody(game);
}
});
void test('native Ecaz normal Poison role and selected skilled role change the winner without increasing the printed death bounty', () => {
for (const rules of ['basic', 'advanced'] as const) {
const setup = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'master-of-assassins', rules });
const actor = player(setup, 'ecaz').id, target = player(setup, 'emperor').id;
for (const hidden of [false, true]) {
let game = stageAdvancedNativeSkillBattle(setup, actor, target);
const poison = take(game, actor, c => c.kind === 'poison');
const selected = hidden ? player(game, 'ecaz').leaders.find(l => l.id === trainer(game, actor).leader)!
: player(game, 'ecaz').leaders.find(l => l.id !== trainer(game, actor).leader && l.strength === 4)!;
assert.equal(selected.strength, 4);
const enemy = player(game, 'emperor').leaders.find(l => l.id === enemyLeader(game))!;
const funds = wallets(game), enemyReserves = player(game, 'emperor').reserves;
const normalSuk = !hidden && trainer(game, target).skill === 'suk-graduate' ? 1 : 0;
game = openAdvancedNativeSkillBattle(game, actor, target, hidden);
game = act(game, actor, { type: 'battlePlan', dial: 1, support: rules === 'advanced' ? 1 : 0, leader: selected.id, weapon: poison.id });
game = act(game, target, { type: 'battlePlan', dial: 7, support: rules === 'advanced' ? 7 : 0, leader: enemy.id });
game = finishAdvancedNativeSkillAftermath(resolve(game, actor, target));
assert.equal(game.lastBattleContext!.winner, hidden ? actor : target);
assert.equal(player(game, 'ecaz').leaders.find(l => l.id === selected.id)!.dead, false);
assert.equal(player(game, 'emperor').leaders.find(l => l.id === enemy.id)!.dead, true);
assert.equal(player(game, 'ecaz').spice, funds[actor] - (rules === 'advanced' ? 1 : 0) + (hidden ? enemy.strength : 0));
assert.equal(player(game, 'emperor').spice, funds[target] - (rules === 'advanced' ? 7 : 0) + (hidden ? 0 : enemy.strength));
assert.equal(player(game, 'ecaz').tanks, hidden ? 1 : 8);
assert.equal(player(game, 'ecaz').forces['wind_pass:14'] ?? 0, hidden ? 7 : 0);
assert.equal(player(game, 'emperor').tanks, hidden ? 8 : 7 - normalSuk);
assert.equal(player(game, 'emperor').reserves, enemyReserves + normalSuk);
assert.equal(game.discard.filter(c => c.id === poison.id).length, hidden ? 0 : 1);
assert.equal(player(game, 'ecaz').hand.some(c => c.id === poison.id), hidden);
custody(game);
}
}
});
void test('native Ecaz Suk uses actual ordinary casualties: face-up one-to-reserves versus selected skilled keep-one and return-two', () => {
for (const rules of ['basic', 'advanced'] as const) {
const setup = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'suk-graduate', rules });
const actor = player(setup, 'ecaz').id, target = player(setup, 'emperor').id;
for (const hidden of [false, true]) {
let game = stageAdvancedNativeSkillBattle(setup, actor, target);
const selected = hidden ? trainer(game, actor).leader
: player(game, 'ecaz').leaders.find(l => l.id !== trainer(game, actor).leader && l.strength === 4)!.id;
const enemy = enemyLeader(game), funds = wallets(game), reserves = player(game, 'ecaz').reserves;
game = openAdvancedNativeSkillBattle(game, actor, target, hidden);
game = act(game, actor, { type: 'battlePlan', dial: 5, support: rules === 'advanced' ? 5 : 0, leader: selected });
game = act(game, target, { type: 'battlePlan', dial: 0, support: 0, leader: enemy });
game = finishAdvancedNativeSkillAftermath(resolve(game, actor, target));
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(player(game, 'ecaz').spice, funds[actor] - (rules === 'advanced' ? 5 : 0));
if (hidden) {
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw new Error('Expected native ordinary casualty rescue.');
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 5, elite: 0, paidNormal: rules === 'advanced' ? 5 : 0, paidElite: 0 });
const pending = game;
const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === 'wind_pass:14');
assert.ok(choice >= 0);
game = finishAdvancedNativeSkillAftermath(act(game, actor, { type: 'decision', event: game.decision.event, choice }));
assert.equal(player(game, 'ecaz').tanks, 2);
assert.equal(player(game, 'ecaz').forces['wind_pass:14'], 4);
assert.equal(player(game, 'ecaz').reserves, reserves + 2);
for (const difficulty of DIFFICULTIES) {
const own = viewGame(reload(pending), actor);
own.players.find(p => p.id === actor)!.bot = difficulty;
const rescued = finishAdvancedNativeSkillAftermath(act(pending, actor, botActions(own)[0]));
assert.equal(player(rescued, 'ecaz').tanks, 2);
assert.equal(player(rescued, 'ecaz').spice, funds[actor] - (rules === 'advanced' ? 5 : 0));
assert.equal(player(rescued, 'ecaz').forces['wind_pass:14'], 4);
assert.equal(player(rescued, 'ecaz').reserves, reserves + 2);
custody(rescued);
}
} else {
assert.equal(player(game, 'ecaz').tanks, 4);
assert.equal(player(game, 'ecaz').forces['wind_pass:14'], 3);
assert.equal(player(game, 'ecaz').reserves, reserves + 1);
}
assert.equal(player(game, 'emperor').tanks, 8);
custody(game);
}
}
});
void test('real native Ecaz trainer death returns its skill; eligible own ordinary and physical Ghola revival offer optional replacement', () => {
for (const rules of ['basic', 'advanced'] as const) {
const setup = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'warmaster', rules });
const actor = player(setup, 'ecaz').id, target = player(setup, 'emperor').id;
const assigned = structuredClone(trainer(setup, actor));
let dead = stageAdvancedNativeSkillBattle(setup, actor, target);
const poison = take(dead, target, c => c.kind === 'poison');
const printed = player(dead, 'ecaz').leaders.find(l => l.id === assigned.leader)!.strength;
const funds = wallets(dead);
dead = openAdvancedNativeSkillBattle(dead, actor, target);
dead = act(dead, actor, { type: 'battlePlan', dial: 0, support: 0, leader: assigned.leader });
dead = act(dead, target, { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader(dead), weapon: poison.id });
dead = finishAdvancedNativeSkillAftermath(resolve(dead, actor, target));
assert.equal(dead.lastBattleContext!.winner, target);
assert.equal(player(dead, 'ecaz').leaders.find(l => l.id === assigned.leader)!.dead, true);
assert.equal(dead.leaderSkills!.assignments.some(a => a.owner === actor), false);
assert.equal(dead.leaderSkills!.deck.filter(c => c === assigned.skill).length, 1);
assert.deepEqual(wallets(dead), { ...funds, [target]: funds[target] + printed });
assert.equal(player(dead, 'ecaz').tanks, 8);
custody(dead);
for (const mode of ['ordinary', 'ghola'] as const) {
let game = reload(dead);
// Explicit conserved Revival position, not a claimed played turn history.
// Ordinary revival stages the other four original discs dead to open the
// native five-disc first cohort. Ghola instead uses only the actual death.
Object.assign(game, { phase: 4, active: null, ready: [], decision: null, response: null,
phaseOpening: null, revivalRules: newRevivalRules() });
for (const seat of game.players) seat.leaderRevived = false;
if (mode === 'ordinary') for (const leader of player(game, 'ecaz').leaders) if (leader.id !== assigned.leader) {
leader.dead = true; leader.deaths = 1;
}
const before = wallets(game);
let ghola: Card | undefined;
if (mode === 'ordinary') {
assert.equal(viewGame(game, actor).revival.leaders.find(l => l.id === assigned.leader)!.cost, printed);
game = act(game, actor, { type: 'reviveLeader', leader: assigned.leader });
} else {
ghola = take(game, actor, c => c.effect === 'ghola');
assert.ok(viewGame(game, actor).ghola.leaders.some(l => l.id === assigned.leader));
game = responses(act(game, actor, { type: 'card', card: ghola.id, leader: assigned.leader }));
}
assert.equal(game.decision?.kind, 'leaderSkillRevival');
assert.equal(player(game, 'ecaz').leaders.find(l => l.id === assigned.leader)!.dead, false);
assert.equal(player(game, 'ecaz').leaders.find(l => l.id === assigned.leader)!.deaths, 1);
assert.equal(player(game, 'ecaz').leaderRevived, mode === 'ordinary');
assert.deepEqual(wallets(game), { ...before, [actor]: before[actor] - (mode === 'ordinary' ? printed : 0) });
if (ghola) {
assert.equal(player(game, 'ecaz').hand.some(c => c.id === ghola!.id), false);
assert.equal(game.discard.filter(c => c.id === ghola!.id).length, 1);
}
const event = viewGame(game, actor).leaderSkills!.offer!.event;
const declined = act(game, actor, { type: 'leaderSkill', event, mode: 'decline' });
assert.equal(declined.leaderSkills!.assignments.some(a => a.owner === actor), false);
assert.equal(player(declined, 'ecaz').leaders.find(l => l.id === assigned.leader)!.dead, false);
custody(declined);
game = act(game, actor, { type: 'leaderSkill', event, mode: 'draw' });
const own = viewGame(game, actor).leaderSkills!;
const offered = own.offer!.cards;
assert.equal(offered.length, 2);
const skill = offered.find(c => !own.unavailableSkills?.[c])!;
game = act(game, actor, { type: 'leaderSkill', event, skill, leader: assigned.leader });
assert.deepEqual(trainer(game, actor), { owner: actor, leader: assigned.leader, skill });
assert.equal(game.leaderSkills!.deck.includes(offered.find(c => c !== skill)!), true);
assert.deepEqual(wallets(game), wallets(declined));
custody(game);
}
}
});
void test('original Emperor Ambassador charges placement then pays five from the Bank and resumes the real entrant with Ecaz training intact', () => {
for (const rules of ['basic', 'advanced'] as const) {
let game = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'warmaster', rules });
const actor = player(game, 'ecaz').id, entrant = player(game, 'atreides').id;
const assigned = structuredClone(trainer(game, actor));
const originalTokens = game.ecazAmbassadors!.tokens.map(t => ({ id: t.id, effect: t.effect }));
// Explicit controlled, conserved post-setup cohort and board position. This
// is not natural Ambassador history or a second source of fixed entropy.
// Swap custody only: retain all eleven original token identities/effects.
const state = game.ecazAmbassadors!;
const token = state.tokens.find(t => t.effect === 'emperor')!;
if (!state.cohort.includes(token.id)) {
const replaced = state.tokens.find(t => t.id === state.cohort[0])!;
state.cohort[0] = token.id;
replaced.zone = 'pool';
token.zone = 'supply';
}
for (const seat of game.players) {
seat.reserves += Object.values(seat.forces).reduce((sum, n) => sum + n, 0);
seat.forces = {};
if (seat.elites) {
seat.elites.reserves += Object.values(seat.elites.forces).reduce((sum, n) => sum + n, 0);
seat.elites.forces = {};
}
}
custody(game);
game = advanceAdvancedNativeSkillsToPhase(game, 4);
const before = wallets(game);
for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
assert.equal(game.decision?.kind, 'ecazPlacement');
game = responses(act(game, actor, { type: 'decision', token: token.id, territory: 'carthag' }));
assert.equal(player(game, 'ecaz').spice, before[actor] - 1);
assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.location, 'carthag');
game = act(game, actor, { type: 'decision', decline: true });
for (let i = 0; (game.phaseOpening || game.response || game.decision || game.active !== entrant) && i < 100; i++) game = advancedNativeStep(game);
const entrantFunds = player(game, 'atreides').spice, entrantReserves = player(game, 'atreides').reserves;
game = responses(act(game, entrant, { type: 'ship', territory: 'carthag', sector: 11, amount: 1 }));
assert.equal(game.decision?.kind, 'ecazAmbassador');
assert.equal(viewGame(game, actor).ambassadorEntry!.stage, 'offer');
game = responses(act(game, actor, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: actor }));
assert.equal(player(game, 'ecaz').spice, before[actor] - 1 + 5);
assert.equal(player(game, 'atreides').spice, entrantFunds - 1);
assert.equal(player(game, 'atreides').reserves, entrantReserves - 1);
assert.equal(player(game, 'atreides').forces['carthag:11'], 1);
assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.zone, 'used');
assert.deepEqual(game.ecazAmbassadors!.tokens.map(t => ({ id: t.id, effect: t.effect })), originalTokens);
assert.deepEqual(trainer(game, actor), assigned);
assert.equal(game.active, entrant);
assert.equal(player(game, 'atreides').shipped, true);
game = act(game, entrant, { type: 'endMovement' });
assert.notEqual(game.active, entrant);
custody(game);
}
});