315 lines
19 KiB
TypeScript
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);
|
|
}
|
|
});
|