Files
Dune/tests/discovery-native-e3-skills-runtime.test.ts
T

280 lines
21 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { leaders, treacheryDeck } from '../game/cards';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { validateDiscoveryState } from '../game/discoveries';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { strongholdControllers } from '../game/stronghold-cards';
import { ownedTech } from '../game/tech-tokens';
import { traitorDeck } from '../game/traitors';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
import {
advanceDiscoveryNativeE3Skills as advance, completeDiscoveryNativeE3SkillsSetup,
createDiscoveryNativeE3SkillsFixture as fixture, createDiscoveryNativeE3SkillsRevivalFixture,
discoveryNativeE3SkillsClean as clean, discoveryNativeE3SkillsPlayer as player,
discoveryNativeE3SkillsPolicy as policy, initializeDiscoveryNativeE3Skills,
nextDiscoveryNativeE3SkillsStep, resolveDiscoveryNativeE3SkillsBattle,
finishDiscoveryNativeE3SkillsBattles,
revealDiscoveryNativeE3SkillsPlans,
type DiscoveryNativeE3SkillsOptions,
} from './fixture-discovery-native-e3-skills';
function custody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players); validateDiscoveryState(game.discoveries!);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id).sort();
assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort());
const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort();
assert.deepEqual(skills, LEADER_SKILL_CARDS.map(c => c.id).sort());
const retired = game.moritaniAssassinate?.opportunities.filter(receipt => receipt.stage === 'replaced').map(receipt => receipt.card!) ?? [];
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired].sort(),
traitorDeck(game.players).sort());
for (const p of game.players) {
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'fremen' ? 3 : 5);
for (const [key, amount] of Object.entries(p.elites.forces)) assert.ok(amount <= (p.forces[key] ?? 0));
}
}
}
function assassinate(game: Game, card: string | null): Game {
const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate');
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected actual original nested loss opportunity.');
return applyAction(structuredClone(game), d.player, { type: 'decision', event: d.event,
...(card === null ? { decline: true } : { card }) });
}
void test('authenticated original Moritani/classic Discovery preserves native all14 training before Traitors at Basic/Advanced module seat boundaries', () => {
const cases: { options: DiscoveryNativeE3SkillsOptions; counts: number[] }[] = [
{ options: { rules: 'basic', tech: false, strongholds: false }, counts: [2, 6] },
{ options: { rules: 'basic', tech: true, strongholds: false }, counts: [3, 6] },
{ options: { rules: 'advanced', tech: false, strongholds: false }, counts: [2, 6] },
{ options: { rules: 'advanced', tech: true, strongholds: false }, counts: [3, 6] },
{ options: { rules: 'advanced', tech: false, strongholds: true }, counts: [2, 6] },
{ options: { rules: 'advanced', tech: true, strongholds: true }, counts: [3, 6] },
];
const factions: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'];
for (const { options, counts } of cases) for (const count of counts) {
const lobby = createGame('AUTHENTICATEDE3SKILLS', newPlayer('original-m', 'Original Moritani', 'moritani'), options.rules === 'advanced', ['ecaz']);
for (let i = 1; i < count; i++) joinGame(lobby, newPlayer(`original-${i}`, `Original ${factions[i]}`, factions[i]));
const before = structuredClone(lobby), setup = initializeDiscoveryNativeE3Skills({ ...options, initial: lobby });
assert.deepEqual(lobby, before);
assert.deepEqual(setup.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })),
before.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })));
assert.ok(setup.players.every(p => p.traitors.length === 0));
const after = completeDiscoveryNativeE3SkillsSetup(setup), guild = after.players.find(p => p.faction === 'guild')!.id;
assert.equal(after.leaderSkills!.assignments.find(a => a.owner === guild)!.skill, 'suk-graduate');
assert.equal(after.players.find(p => p.faction === 'moritani')!.reserves, 14);
assert.equal(Object.values(after.players.find(p => p.faction === 'moritani')!.forces).reduce((a, b) => a + b, 0), 6);
assert.equal(!!after.moritaniAssassinate, options.rules === 'advanced');
if (after.strongholdCards) assert.ok(Object.values(after.strongholdCards.owners).every(owner => owner === null));
custody(after);
}
});
void test('printed Discovery reveal, actual first-Storm Tech/END-Mentat claims, free entry and paid nested shipment preserve source positions and native wallets', () => {
const f = fixture({ tech: true, strongholds: true });
assert.ok(Object.values(f.setup.techTokens!).every(t => t.owner === null));
for (const p of f.afterFirstStorm.players) assert.equal(ownedTech(f.afterFirstStorm.techTokens, p.id).length, 1);
assert.ok(Object.values(f.beforeFirstMentat.strongholdCards!.owners).every(owner => owner === null));
const claimed = applyAction(structuredClone(f.beforeFirstMentat), f.firstMentatStep.actor, f.firstMentatStep.action);
assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(f.beforeFirstMentat.players, false));
assert.equal(claimed.strongholdCards!.owners.tueks_sietch, f.moritani);
const revealedToken = f.afterReveal.discoveries!.tokens.find(t => t.id === f.token)!;
assert.equal(revealedToken.territory, f.source.split(':')[0]);
assert.equal(player(f.beforeReveal, f.opponent).forces[f.source], 3);
assert.equal(player(f.entry.after, f.opponent).forces[f.source] ?? 0, 0);
assert.equal(player(f.entry.after, f.opponent).forces['cistern:0'], 3);
assert.equal(player(f.entry.after, f.opponent).spice, player(f.entry.before, f.opponent).spice);
assert.equal(player(f.entry.after, f.opponent).reserves, player(f.entry.before, f.opponent).reserves);
assert.equal(player(f.shipment.after, f.moritani).forces['cistern:0'], 1);
assert.equal(player(f.shipment.after, f.moritani).reserves, player(f.shipment.before, f.moritani).reserves - 1);
assert.equal(player(f.shipment.after, f.moritani).spice, player(f.shipment.before, f.moritani).spice - 1);
for (const game of [f.afterSetup, f.afterFirstStorm, f.afterReveal, f.entry.after, f.shipment.after, f.pending]) custody(game);
});
void test('normal Guild Suk resolves physical casualty rescue BEFORE nested after-loss assassination; printed3 bounty and exact skill return never replay support or casualties', () => {
const f = fixture({ tech: true, strongholds: true }), pending = f.pending;
assert.equal(pending.lastBattleContext?.winner, f.opponent);
assert.equal(pending.lastBattleContext?.result, 'normal');
assert.equal(pending.lastBattleContext?.sukRescue?.completed, true);
assert.equal(player(pending, f.opponent).tanks, player(f.revealed, f.opponent).tanks + 2);
assert.equal(player(pending, f.opponent).reserves, player(f.revealed, f.opponent).reserves + 1);
assert.equal(player(pending, f.opponent).forces[f.location], 3);
assert.equal(player(pending, f.moritani).tanks, player(f.revealed, f.moritani).tanks + 6);
assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
assert.equal(player(pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1);
assert.equal(viewGame(pending, f.moritani).moritaniAssassinate!.pending!.territory, 'cistern');
const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!;
assert.equal(target.name, 'Master Bewt'); assert.equal(target.strength, 3); assert.notEqual(target.id, f.opponentLeader);
const originalCounters = pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks }));
const reserve = [...pending.traitorReserve!], ownTraitors = [...player(pending, f.moritani).traitors];
let game = assassinate(pending, f.target);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === f.target), false);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
assert.equal(player(game, f.moritani).spice, player(pending, f.moritani).spice + target.strength);
assert.equal(player(game, f.opponent).spice, player(pending, f.opponent).spice);
assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks })), originalCounters);
assert.deepEqual(game.techTokens, pending.techTokens); assert.deepEqual(game.strongholdCards, pending.strongholdCards);
assert.equal(game.decision?.kind, 'battleCards'); assert.equal(game.decision?.player, f.opponent);
game = applyAction(structuredClone(game), f.opponent, { type: 'decision', discard: f.opponentCards });
game = advance(game, g => !g.battle && clean(g));
const lost = ownedTech(pending.techTokens, f.moritani)[0]; assert.ok(lost);
assert.equal(game.techTokens![lost].owner, f.opponent);
for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1);
game = finishDiscoveryNativeE3SkillsBattles(game);
game = advance(game, g => g.phase === 8);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.territory, 'cistern');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.replacement, reserve[0]);
assert.deepEqual(game.traitorReserve, reserve.slice(1));
assert.deepEqual(player(game, f.moritani).traitors, [...ownTraitors.filter(c => c !== f.target), reserve[0]]);
assert.equal('replacement' in viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
game = advance(game, g => g.turn === 3 && g.phase === 4 && clean(g));
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.opponent, { type: 'reviveLeader', leader: f.target }), /All leaders must die/);
assert.deepEqual(game, before); custody(game);
});
void test('earned Tuek Worthless benefits remain source-local: retained card pays in Tuek, never in the separately revealed Cistern', () => {
for (const battle of ['nested', 'tueks_sietch'] as const) {
const f = fixture({ strongholds: true, battle });
assert.equal(f.revealed.strongholdCards!.owners.tueks_sietch, f.moritani);
assert.deepEqual(quoteStrongholdFactionsBattle(f.revealed).strongholdIncome,
battle === 'nested' ? [] : [{ player: f.moritani, amount: 4 }]);
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1 + (battle === 'nested' ? 0 : 4));
assert.equal(player(f.pending, f.opponent).tanks, 2);
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
custody(f.pending);
}
});
void test('Basic Discovery keeps normal Suk and optional original Tech capture without Advanced support, Strongholds or assassination', () => {
for (const tech of [false, true]) {
const f = fixture({ rules: 'basic', tech, strongholds: false });
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
assert.equal(f.pending.lastBattleContext?.sukRescue?.completed, true);
assert.equal(player(f.pending, f.opponent).tanks, 2); assert.equal(player(f.pending, f.moritani).tanks, 6);
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice);
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice);
assert.equal(f.pending.moritaniAssassinate, undefined); assert.ok(!f.pending.strongholdCards);
assert.equal(f.pending.leaderSkills!.assignments.find(a => a.leader === f.target)!.skill, 'suk-graduate');
if (tech) {
const lost = ownedTech(f.revealed.techTokens, f.moritani)[0]; assert.ok(lost);
assert.equal(f.pending.techTokens![lost].owner, f.opponent);
}
custody(f.pending);
}
});
void test('a normal native Traitor reveal forfeits assassination for the entire game, including a later real nested loss', () => {
const f = fixture({ normalCall: true });
assert.equal(f.pending.lastBattleContext?.result, 'traitor');
assert.equal(f.pending.lastBattleContext?.winner, f.moritani);
assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true);
let game = advance(finishDiscoveryNativeE3SkillsBattles(structuredClone(f.pending)), g => g.turn === 3 && g.phase === 5 && clean(g));
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0); p.forces = {};
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0); p.elites.forces = {}; }
if (p.advisors) p.advisors = {};
}
for (const actor of [f.moritani, f.opponent]) { const p = player(game, actor); p.reserves -= 4; p.forces['cistern:0'] = 4; }
holdMoritaniStrongholdsTraitor(game, f.moritani, f.target);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const own = player(game, f.moritani).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
const enemy = player(game, f.opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(own && enemy);
game = advance(game, g => g.phase === 6 && clean(g)); const actor = game.active!;
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'cistern', target: actor === f.moritani ? f.opponent : f.moritani });
game = advance(game, g => !!g.battle && !g.battle.revealed && !nextDiscoveryNativeE3SkillsStep(g));
game = revealDiscoveryNativeE3SkillsPlans(game, [
{ actor: f.moritani, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
]);
game = resolveDiscoveryNativeE3SkillsBattle(game, f.moritani);
assert.equal(game.lastBattleContext?.result, 'normal'); assert.equal(game.lastBattleContext?.winner, f.opponent);
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(game.moritaniAssassinateCallEvents!.length, 1);
assert.deepEqual(game.moritaniAssassinate!.opportunities, []);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, false); custody(game);
});
void test('four minimal policies actually kill the eligible trained disc or decline the real used-disc-only menu; neither branch replays native resources', () => {
const f = fixture({ tech: true, strongholds: true });
let empty = structuredClone(f.game);
holdMoritaniStrongholdsTraitor(empty, f.moritani, f.opponentLeader);
empty = resolveDiscoveryNativeE3SkillsBattle(revealDiscoveryNativeE3SkillsPlans(empty, f.plans), f.moritani);
assert.deepEqual(viewGame(empty, f.moritani).moritaniAssassinate!.pending!.cards, []);
for (const difficulty of DIFFICULTIES) {
const action = policy(structuredClone(f.pending), f.moritani, difficulty)[0]; assert.ok(action, difficulty);
let game = applyAction(structuredClone(f.pending), f.moritani, action);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.card, f.target);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
assert.equal(player(game, f.moritani).spice, player(f.pending, f.moritani).spice + 3);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
game = advance(game, g => !g.battle && clean(g));
assert.equal(player(game, f.opponent).tanks, 2); assert.equal(player(game, f.moritani).tanks, 6); custody(game);
const decline = policy(structuredClone(empty), f.moritani, difficulty)[0]; assert.ok(decline, difficulty);
const declined = applyAction(structuredClone(empty), f.moritani, decline);
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assert.deepEqual(declined.leaderSkills, empty.leaderSkills);
assert.equal(player(declined, f.moritani).spice, player(empty, f.moritani).spice);
assert.equal(player(declined, f.opponent).leaders.find(l => l.id === f.target)!.dead, false);
custody(advance(declined, g => !g.battle && clean(g)));
}
});
void test('five actual original battles open native own revival; one real printed fee supplies private optional replacement without staged deaths or clocks', () => {
const f = createDiscoveryNativeE3SkillsRevivalFixture();
const assignment = f.beforeDeaths.leaderSkills!.assignments.find(a => a.owner === f.moritani)!;
assert.equal(new Set(f.deaths.map(d => d.leader)).size, 5);
assert.equal(new Set(f.deaths.map(d => d.winnerLeader)).size, 5);
assert.equal(new Set(f.deaths.map(d => d.territory)).size, 5);
for (const [index, death] of f.deaths.entries()) {
assert.equal(player(death.after, f.moritani).leaders.filter(l => l.dead).length, index + 1);
assert.equal(player(death.after, f.moritani).leaders.find(l => l.id === death.leader)!.deaths,
player(death.before, f.moritani).leaders.find(l => l.id === death.leader)!.deaths + 1);
assert.equal(death.after.lastBattleContext?.winner, f.opponent);
assert.equal(player(death.after, f.opponent).hand.filter(c => c.id === f.weapon).length, 1);
assert.equal(death.after.discard.some(c => c.id === f.weapon), false);
assert.equal(player(death.after, f.moritani).tanks, player(death.before, f.moritani).tanks + 1);
assert.equal(player(death.after, f.opponent).tanks, player(death.before, f.opponent).tanks);
custody(death.after);
}
assert.equal(f.revivalWindow.leaderSkills!.assignments.some(a => a.owner === f.moritani), false);
assert.equal(f.revivalWindow.leaderSkills!.deck.filter(c => c === assignment.skill).length, 1);
const printed = player(f.revivalWindow, f.moritani).leaders.find(l => l.id === f.leader)!.strength;
assert.equal(player(f.offered, f.moritani).spice, player(f.revivalWindow, f.moritani).spice - printed);
assert.equal(player(f.offered, f.moritani).leaders.find(l => l.id === f.leader)!.dead, false);
assert.equal(player(f.offered, f.moritani).leaders.filter(l => l.dead).length, 4);
const event = f.offered.leaderSkills!.offers[f.moritani].event;
assert.deepEqual(viewGame(f.offered, f.moritani).leaderSkills!.offer!.cards, []);
assert.equal(viewGame(f.offered, f.opponent).leaderSkills!.offer, null);
const declined = applyAction(structuredClone(f.offered), f.moritani, { type: 'leaderSkill', event, mode: 'decline' });
assert.equal(declined.leaderSkills!.offers[f.moritani], undefined);
assert.equal(declined.leaderSkills!.assignments.some(a => a.owner === f.moritani), false);
assert.equal(player(declined, f.moritani).spice, player(f.offered, f.moritani).spice); custody(declined);
const drawn = applyAction(structuredClone(f.offered), f.moritani, { type: 'leaderSkill', event, mode: 'draw' });
const offer = viewGame(drawn, f.moritani).leaderSkills!.offer!;
assert.equal(offer.cards.length, 2); assert.equal(viewGame(drawn, f.opponent).leaderSkills!.offer, null);
const own = viewGame(drawn, f.moritani).leaderSkills!;
const skill = offer.cards.find(c => !own.unavailableSkills?.[c]); assert.ok(skill);
const selected = applyAction(drawn, f.moritani, { type: 'leaderSkill', event, skill, leader: f.leader });
assert.deepEqual(selected.leaderSkills!.assignments.find(a => a.owner === f.moritani), { owner: f.moritani, skill, leader: f.leader });
assert.equal(selected.leaderSkills!.offers[f.moritani], undefined);
assert.equal(player(selected, f.moritani).spice, player(f.offered, f.moritani).spice);
assert.equal(selected.players.some(p => p.leaders.some(l => l.capturedBy || l.gholaBy)), false);
custody(selected);
});