Files
Dune/tests/ecaz-skills-modules-runtime.test.ts

253 lines
17 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import {
advanceEcazSkillsModules as advance, assertEcazSkillsModulesCustody as custody,
createEcazSkillsModulesFixture as fixture, ecazSkillsModulesAction as act,
ecazSkillsModulesPlayer as player, ecazSkillsModulesTrainer as trainer,
reloadEcazSkillsModules as reload, revealEcazSkillsModulesBattle as reveal,
settleEcazSkillsModulesBattle as settle,
} from './fixture-ecaz-skills-modules';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'A rejected combined-module action must preserve the real source.');
}
function policy(game: Game, actor: string, difficulty: typeof DIFFICULTIES[number]): Action {
const view = viewGame(reload(game), actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action); return action;
}
function collection(game: Game, actor: string): number {
if (game.phase !== 7 || game.phaseOpening) return 0;
return quoteSpiceCollection({ ...game, players: game.players.map(p => ({ ...p, spice: 0 })) }).receipts
.filter(r => r.player === actor).reduce((sum, r) => sum + r.strongholds + r.collected, 0);
}
void test('original Ecaz normal and selected Suk rescue physical ordinary losses before winner cards and earned Tech', () => {
for (const rules of ['basic', 'advanced'] as const) {
for (const stronghold of rules === 'advanced' ? [false, true] : [false]) {
for (const band of ['normal', 'skilled'] as const) {
const f = fixture({ rules, tech: true, stronghold, band });
const assigned = structuredClone(trainer(f.game, f.owner));
const before = reveal(f), wallet = player(before, f.owner).spice, reserves = player(before, f.owner).reserves;
const defeatedTokens = ownedTech(before.techTokens, f.opponent);
assert.equal(defeatedTokens.length, 1, 'Actual three-seat first Storm assigns one physical token to each original seat.');
const supportCost = rules === 'advanced' ? stronghold ? 3 : 5 : 0;
let game = settle(before, band === 'skilled' ? 'suk' : 'cards');
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).spice, wallet - supportCost);
assert.equal(game.techTokens![defeatedTokens[0]].owner, f.opponent, 'Neither committed rescue nor optional card retention steals technology early.');
if (band === 'skilled') {
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw Error('Expected physical trained-disc Suk rescue.');
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 5, elite: 0, paidNormal: rules === 'advanced' ? 5 : 0, paidElite: 0 });
reject(game, f.opponent, { type: 'decision', event: game.decision.event, choice: 0 });
reject(game, f.owner, { type: 'decision', event: `${game.decision.event}-stale`, choice: 0 });
reject(game, f.owner, { type: 'decision', event: game.decision.event, choice: game.decision.options.length });
const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === f.location);
assert.ok(choice >= 0);
game = settle(act(game, f.owner, { type: 'decision', event: game.decision.event, choice }), 'cards');
assert.equal(player(game, f.owner).forces[f.location], 4);
assert.equal(player(game, f.owner).reserves, reserves + 2);
assert.equal(player(game, f.owner).tanks, 2);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
} else {
assert.equal(player(game, f.owner).forces[f.location], 3);
assert.equal(player(game, f.owner).reserves, reserves + 1);
assert.equal(player(game, f.owner).tanks, 4);
assert.equal(game.pendingSukRescue, null);
}
assert.equal(game.decision?.kind, 'battleCards');
assert.equal(game.decision?.player, f.owner);
reject(game, f.owner, { type: 'decision', token: defeatedTokens[0] });
assert.equal(game.techTokens![defeatedTokens[0]].owner, f.opponent);
game = settle(act(game, f.owner, { type: 'decision', discard: [] }));
assert.equal(game.techTokens![defeatedTokens[0]].owner, f.owner);
assert.equal(player(game, f.opponent).tanks, 8);
assert.deepEqual(trainer(game, f.owner), assigned);
custody(game);
}
}
}
});
void test('all four real legal policies rescue trained native Ecaz counters and resolve its physical card/Tech continuation', () => {
const f = fixture({ tech: true, stronghold: true, band: 'skilled' });
const revealed = reveal(f), source = settle(revealed, 'suk');
assert.equal(source.decision?.kind, 'sukRescue');
for (const difficulty of DIFFICULTIES) {
const rescued = settle(act(source, f.owner, policy(source, f.owner, difficulty)), 'cards');
assert.equal(rescued.pendingSukRescue, null);
assert.equal(player(rescued, f.owner).tanks, 2);
assert.equal(player(rescued, f.owner).reserves, player(revealed, f.owner).reserves + 2);
assert.equal(player(rescued, f.owner).forces[f.location], 4);
assert.equal(player(rescued, f.owner).spice, player(revealed, f.owner).spice - 3);
assert.equal(rescued.decision?.kind, 'battleCards');
const cardAction = policy(rescued, f.owner, difficulty);
const finished = settle(act(rescued, f.owner, cardAction));
assert.equal(finished.battle, null);
assert.equal(finished.lastBattleContext!.winner, f.owner);
for (const token of ownedTech(revealed.techTokens, f.opponent)) assert.equal(finished.techTokens![token].owner, f.owner);
assert.equal(player(finished, f.owner).tanks, 2);
custody(finished);
}
});
void test('real Arrakeen subsidy and ordinary normal Banker count only the opposing paid Bank leg', () => {
const f = fixture({ kind: 'income', band: 'normal', tech: true, stronghold: true, opponentDial: 4, defense: true });
const before = reveal(f), sources = before.spiceBankerIncome!.sources.length;
const game = settle(before, 'cards');
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 3);
assert.equal(player(game, f.opponent).spice, player(before, f.opponent).spice - 4);
const payments = game.spiceBankerIncome!.sources.slice(sources).filter(r => r.source.kind === 'battle-support');
assert.deepEqual(payments.map(r => r.source.bankLegs), [
[{ payer: before.battle!.attacker, amount: before.battle!.attacker === f.owner ? 3 : 4 }],
[{ payer: before.battle!.defender, amount: before.battle!.defender === f.owner ? 3 : 4 }],
]);
assert.deepEqual(payments.filter(r => r.grant).map(r => ({ owner: r.grant!.owner, amount: r.grant!.amount })), [{ owner: f.owner, amount: 1 }]);
const finished = settle(game);
const beforeMentat = advance(finished, g => g.phase === 7 && !g.response && !g.phaseOpening && !g.decision);
const wallet = player(beforeMentat, f.owner).spice;
const mentat = advance(beforeMentat, g => g.phase === 8 && !g.response && !g.phaseOpening && !g.decision);
assert.equal(player(mentat, f.owner).spice, wallet + 1, 'The one front-of-shield grant becomes wallet spice only at actual Mentat.');
custody(mentat);
});
void test('original Ambassador Duke6 receives normal Warmaster1, never the separate trained-disc3, and survives release with native assignment intact', () => {
for (const rules of ['basic', 'advanced'] as const) {
for (const band of ['normal', 'skilled'] as const) {
const f = fixture({ rules, kind: 'duke', tech: true, stronghold: rules === 'advanced', band });
assert.ok(f.acquisition);
assert.equal(player(f.acquisition.after, f.owner).spice, player(f.acquisition.before, f.owner).spice - 1);
assert.equal(player(f.acquisition.after, f.opponent).spice, player(f.acquisition.before, f.opponent).spice - 1);
assert.equal(f.acquisition.after.dukeVidal!.source, 'ecaz');
const assigned = structuredClone(trainer(f.game, f.owner)), native = structuredClone(player(f.game, f.owner).leaders);
const before = reveal(f), game = settle(before);
assert.equal(game.lastBattleContext!.winner, band === 'normal' ? f.owner : f.opponent);
assert.equal(game.dukeVidal!.leader.dead, false);
assert.equal(game.dukeVidal!.leader.usedAt, f.territory);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], [null, null, null]);
assert.deepEqual(trainer(game, f.owner), assigned);
assert.deepEqual(player(game, f.owner).leaders, native);
assert.ok(viewGame(game, f.owner).leaderSkills!.eligibleLeaders.every(l => l.id !== DUKE_VIDAL_ID));
custody(game);
}
const f = fixture({ rules, kind: 'duke', tech: true, stronghold: rules === 'advanced', opponentDial: 3 });
const game = settle(reveal(f));
assert.equal(game.lastBattleContext!.winner, f.opponent, 'Duke6+normal1 loses to printed5+3; borrowing the selected trainer bonus would incorrectly win.');
assert.equal(game.dukeVidal!.leader.dead, false);
custody(game);
}
});
void test('actual retained Carthag adds Snooper to real Duke Shield, while Tuek pays for played Worthless even on printed6 death and paid5 revival', () => {
const defended = fixture({ kind: 'duke', strongholdKind: 'carthag', poison: true, defense: true });
const safe = settle(reveal(defended));
assert.equal(safe.lastBattleContext!.winner, defended.owner);
assert.equal(safe.dukeVidal!.leader.dead, false, 'Retained Carthag adds the printed Snooper property to the original physical Shield.');
custody(safe);
const f = fixture({ kind: 'duke', strongholdKind: 'tueks_sietch', poison: true, opponentDial: 0 });
const assigned = structuredClone(trainer(f.game, f.owner)), native = structuredClone(player(f.game, f.owner).leaders);
const before = reveal(f), dead = settle(before);
assert.equal(dead.lastBattleContext!.winner, f.opponent);
assert.equal(dead.dukeVidal!.leader.dead, true);
assert.equal(dead.dukeVidal!.leader.deaths, 1);
assert.equal(player(dead, f.owner).spice, player(before, f.owner).spice + 2 + collection(dead, f.owner), 'Tuek counts the real Worthless even though Duke dies and Ecaz loses.');
assert.equal(player(dead, f.opponent).spice, player(before, f.opponent).spice + 6 + collection(dead, f.opponent), 'Only the printed shared-disc6 is paid, not normal7 or trained9.');
assert.deepEqual(player(dead, f.owner).leaders, native);
assert.deepEqual(trainer(dead, f.owner), assigned);
const skillInventory = structuredClone(dead.leaderSkills);
const revival = advance(dead, g => g.turn > dead.turn && g.phase === 4 && !g.response && !g.phaseOpening && !g.decision);
const option = viewGame(revival, f.owner).revival.leaders.find(l => l.id === DUKE_VIDAL_ID)!;
assert.equal(option.cost, 5); assert.equal(option.normalCost, 5); assert.equal(option.affordable, true);
reject(revival, f.opponent, { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
const funds = player(revival, f.owner).spice;
let revived = act(revival, f.owner, { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
revived = advance(revived, g => !g.response && !g.pendingRevival);
assert.equal(player(revived, f.owner).spice, funds - 5);
assert.equal(player(revived, f.owner).leaderRevived, true);
assert.equal(revived.dukeVidal!.leader.dead, false);
assert.equal(revived.dukeVidal!.leader.deaths, 1);
assert.deepEqual([revived.dukeVidal!.controller, revived.dukeVidal!.source, revived.dukeVidal!.acquiredTurn], [null, null, null]);
assert.deepEqual(revived.leaderSkills, skillInventory);
assert.deepEqual(trainer(revived, f.owner), assigned);
assert.equal(revived.leaderSkills!.offers[f.owner], undefined);
custody(revived);
});
void test('fresh human IDs retain original module acquisition chronology; insufficient Tech seats and Advanced Hark reject without conversion', () => {
const initial = createGame('HUMANECAZMODULES', newPlayer('human-ecaz', 'Human Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(initial, newPlayer('human-emperor', 'Human Emperor', 'emperor'));
joinGame(initial, newPlayer('human-atreides', 'Human Atreides', 'atreides'));
const snapshot = structuredClone(initial), f = fixture({ initial, kind: 'suk' });
assert.deepEqual(initial, snapshot);
assert.deepEqual(f.game.players.map(p => p.id), snapshot.players.map(p => p.id));
assert.equal(f.owner, 'human-ecaz'); assert.equal(f.opponent, 'human-emperor');
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
const claimed = act(f.beforeFirstMentat, f.firstMentatStep.actor, f.firstMentatStep.action);
assert.equal(claimed.turn, 2); assert.equal(claimed.strongholdCards!.claimedTurn, 1);
assert.equal(claimed.strongholdCards!.owners.arrakeen, f.owner);
const physicalSkills = [...f.offered.leaderSkills!.deck, ...Object.values(f.offered.leaderSkills!.offers).flatMap(o => o.cards)];
assert.deepEqual(physicalSkills.sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
assert.equal(f.offered.players.find(p => p.id === f.owner)!.reserves, 20);
const two = createGame('ECAZTWOTECH', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(two, newPlayer('p', 'Emperor', 'emperor'));
let unsupported = applyAction(two, two.host, { type: 'techTokens', enabled: true });
for (const p of unsupported.players) unsupported = applyAction(unsupported, p.id, { type: 'ready' });
const before = structuredClone(unsupported);
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported)); assert.deepEqual(unsupported, before);
const hark = createGame('ECAZHARK', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(hark, newPlayer('p', 'Emperor', 'emperor')); joinGame(hark, newPlayer('h', 'Harkonnen', 'harkonnen'));
let advancedHark = applyAction(hark, hark.host, { type: 'techTokens', enabled: true });
for (const p of advancedHark.players) advancedHark = applyAction(advancedHark, p.id, { type: 'ready' });
const harkBefore = structuredClone(advancedHark);
assert.throws(() => initializeLeaderSkillsGameForAudit(advancedHark)); assert.deepEqual(advancedHark, harkBefore);
custody(settle(reveal(f)));
});
void test('real two-seat Stronghold and six-seat Basic/Advanced Tech boundaries preserve native Emperor5 and Fremen3 inventories', () => {
const two = createGame('ECAZTWOSTRONG', newPlayer('two-ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(two, newPlayer('two-emperor', 'Emperor', 'emperor'));
const twoCase = fixture({ initial: two, tech: false, stronghold: true });
const twoDone = settle(reveal(twoCase));
assert.equal(twoDone.lastBattleContext!.winner, twoCase.owner);
assert.equal(player(twoDone, twoCase.owner).tanks, 2);
assert.equal(twoCase.afterFirstMentat.strongholdCards!.owners.arrakeen, twoCase.owner);
custody(twoDone);
for (const rules of ['basic', 'advanced'] as const) {
const six = createGame(`ECAZSIX${rules}`, newPlayer('six-ecaz', 'Ecaz', 'ecaz'), rules === 'advanced', ['ecaz']);
for (const faction of ['emperor', 'atreides', 'guild', 'fremen', 'beneGesserit'] as const)
joinGame(six, newPlayer(`six-${faction}`, faction, faction));
const f = fixture({ initial: six, rules, tech: true, stronghold: rules === 'advanced' });
const game = settle(reveal(f));
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).forces[f.location], 4);
assert.equal(player(game, f.owner).tanks, 2);
if (rules === 'advanced') {
assert.equal(game.players.find(p => p.faction === 'fremen')!.elites!.reserves, 3);
assert.equal(game.players.find(p => p.faction === 'emperor')!.elites!.reserves, 5);
} else assert.ok(game.players.every(p => !p.elites));
assert.deepEqual(game.players.map(p => p.id), six.players.map(p => p.id));
custody(game);
}
const basicHark = createGame('ECAZBASICHARK', newPlayer('basic-ecaz', 'Ecaz', 'ecaz'), false, ['ecaz']);
joinGame(basicHark, newPlayer('basic-emperor', 'Emperor', 'emperor'));
joinGame(basicHark, newPlayer('basic-hark', 'Harkonnen', 'harkonnen'));
const allowed = fixture({ initial: basicHark, rules: 'basic', tech: true, stronghold: false });
const done = settle(reveal(allowed));
assert.equal(done.lastBattleContext!.winner, allowed.owner);
assert.equal(player(done, allowed.owner).tanks, 2);
assert.equal(player(done, 'basic-hark').traitors.length, 4);
custody(done);
});