253 lines
17 KiB
TypeScript
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);
|
|
});
|