Files
Dune/tests/fixture-ecaz-skills-modules.ts
T

253 lines
18 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine';
import { territory } from '../game/board';
import { leaders, treacheryDeck, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import type { StrongholdId } from '../game/stronghold-cards';
import { traitorDeck } from '../game/traitors';
import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type EcazSkillsModulesOptions = {
/** Only an original undealt authenticated lobby; preserve its IDs and order. */
initial?: Game;
rules?: 'basic' | 'advanced';
tech?: boolean;
stronghold?: boolean;
kind?: 'suk' | 'duke' | 'income';
skill?: LeaderSkillId;
band?: 'normal' | 'skilled';
strongholdKind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
poison?: boolean;
defense?: boolean;
opponentDial?: number;
};
export type EcazSkillsModulesFixture = {
initial: Game; offered: Game; afterSetup: Game;
beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game;
acquisition: { before: Game; after: Game; actions: StrongholdFactionsNativeStep[] } | null;
beforeBattle: Game; game: Game;
owner: string; opponent: string; trainer: string;
territory: Exclude<StrongholdId, 'hidden_mobile_stronghold'>; location: string;
plans: StrongholdFactionsNativeStep[]; actions: StrongholdFactionsNativeStep[]; staging: string[];
};
export const reloadEcazSkillsModules = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
export const ecazSkillsModulesAction = (g: Game, actor: string, action: Action): Game =>
applyAction(reloadEcazSkillsModules(g), actor, action);
export function ecazSkillsModulesPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
}
export const ecazSkillsModulesTrainer = (game: Game, owner: string) => {
const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner); assert.ok(assignment); return assignment;
};
export function assertEcazSkillsModulesCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
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, p.faction);
for (const [location, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[location] ?? 0));
}
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
}
const physical = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(physical.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.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());
assert.equal(game.players.find(p => p.faction === 'ecaz')!.leaders.length, 5);
assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID);
assert.equal(game.dukeVidal!.leader.strength, 6);
assert.ok(game.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
validateAmbassadors(game.ecazAmbassadors!);
}
const clean = (g: Game) => !g.response && !g.phaseOpening && !g.decision;
/** Responses always precede decisions: consume the original persisted pass queue. */
export function nextEcazSkillsModulesStep(game: Game, hide = true): StrongholdFactionsNativeStep | null {
if (game.response || game.phaseOpening || game.pendingTreacheryDiscard)
return nextStrongholdFactionsNativeStep(game);
const d = game.decision;
if (d?.kind === 'ecazPlacement' || d?.kind === 'ecazAmbassador')
return { actor: d.player, action: { type: 'decision', decline: true } };
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player,
action: { type: 'leaderSkillVisibility', event: d.event, hide } };
if (d?.kind === 'mentatQuestion') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'rihani') return { actor: d.player, action: { type: 'decision', event: d.event, draw: false } };
if (d?.kind === 'sukRescue') {
const maximum = Math.max(...d.options.map(o => o.normal + o.elite));
const choice = d.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal');
return { actor: d.player, action: { type: 'decision', event: d.event,
choice: choice >= 0 ? choice : d.options.findIndex(o => o.normal + o.elite === maximum) } };
}
if (d?.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
return nextStrongholdFactionsNativeStep(game);
}
function step(game: Game, next: StrongholdFactionsNativeStep, actions?: StrongholdFactionsNativeStep[]): Game {
actions?.push(structuredClone(next));
return ecazSkillsModulesAction(game, next.actor, next.action);
}
export function advanceEcazSkillsModules(state: Game, until: (g: Game) => boolean,
actions?: StrongholdFactionsNativeStep[], hide = true, firstDial = 0): Game {
let game = state;
for (let i = 0; i < 1800; i++) {
if (until(game)) return game;
assert.equal(game.status, 'playing');
const next = nextEcazSkillsModulesStep(game, hide); assert.ok(next, 'An original private plan needs its actual owner.');
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstDial;
game = step(game, next, actions);
}
throw Error('Original Ecaz skill/module chronology did not reach its boundary.');
}
function reserveBoard(game: Game): void {
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 = {};
}
}
function place(game: Game, actor: string, location: string, count: number): void {
const p = ecazSkillsModulesPlayer(game, actor); assert.ok(p.reserves >= count);
p.reserves -= count; p.forces[location] = (p.forces[location] ?? 0) + count;
}
function take(game: Game, actor: string, predicate: (c: Card) => boolean, staging: string[]): Card {
const p = ecazSkillsModulesPlayer(game, actor), held = p.hand.find(predicate);
if (held) return held;
const index = game.deck.findIndex(predicate); assert.ok(index >= 0 && p.hand.length < handLimit(p));
const card = game.deck.splice(index, 1)[0]; p.hand.push(card);
staging.push(`Conserved ${card.id}: original remaining Ecaz33 deck to ${actor}'s hand.`); return card;
}
/** Original Ecaz33/all14 setup and first Storm assignment, then actual first END
* Mentat. Only physical offer order, conserved native cards/board and unplayed
* non-worm Spice Cards are staged. No phase, turn, storm, wallet, owner or receipt
* is assigned, and no allied Occupy, E3 pair or overlay module is admitted. */
export function createEcazSkillsModulesFixture(options: EcazSkillsModulesOptions = {}): EcazSkillsModulesFixture {
const rules = options.rules ?? (options.initial?.advanced === false ? 'basic' : 'advanced');
const tech = options.tech ?? true, stronghold = options.stronghold ?? rules === 'advanced';
const kind = options.kind ?? 'suk', band = options.band ?? (kind === 'suk' ? 'skilled' : 'normal');
const requested = options.skill ?? (kind === 'duke' ? 'warmaster' : kind === 'income' ? 'spice-banker' : 'suk-graduate');
const strongholdKind = options.strongholdKind ?? 'arrakeen';
const location = `${strongholdKind}:${territory(strongholdKind).sectors[0]}`;
let game = options.initial ? structuredClone(options.initial)
: createGame('ECAZSKILLSMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), rules === 'advanced', ['ecaz']);
if (!options.initial) for (const faction of ['emperor', 'atreides'] as FactionId[]) joinGame(game, newPlayer(faction, faction, faction));
assert.equal(game.status, 'lobby', 'Only the original undealt authenticated lobby may be continued.');
assert.equal(game.advanced, rules === 'advanced'); assert.deepEqual(game.expansions, ['ecaz']);
assert.ok(game.players.length >= 2 && game.players.length <= 6 && (!tech || game.players.length >= 3));
assert.ok(!stronghold || game.advanced);
assert.ok(game.players.every(p => p.faction === 'ecaz' || ['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit', ...(game.advanced ? [] : ['harkonnen'])].includes(p.faction)));
const owner = game.players.find(p => p.faction === 'ecaz')?.id, opponent = game.players.find(p => p.faction === 'emperor')?.id;
assert.ok(owner && opponent);
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== stronghold) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: stronghold });
const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
game = initializeAdvancedNativeSkillsSetup({ family: 'ecaz', rules, requestedSkill: requested, initial: game });
const offered = structuredClone(game);
for (let i = 0; game.status === 'setup' && i < 200; i++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!;
const skill = actor === owner ? requested : offer.cards.find(c => !own.unavailableSkills?.[c]);
const eligible = new Set(own.eligibleLeaders.map(l => l.id));
const leader = ecazSkillsModulesPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader);
game = step(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions);
} else { const next = nextEcazSkillsModulesStep(game); assert.ok(next); game = step(game, next, actions); }
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game), trainer = ecazSkillsModulesTrainer(game, owner).leader;
reserveBoard(game);
const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 6);
for (const [index, card] of blows.entries()) game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('After original setup, return every native board counter to its own reserve; order only six existing unplayed non-worm Spice Cards, not a claimed random history.');
const firstDial = ['tueks_sietch', 'habbanya_ridge_sietch'].includes(strongholdKind) ? 4 : 0;
game = advanceEcazSkillsModules(game, g => g.phase === 8 && clean(g), actions, true, firstDial);
reserveBoard(game); place(game, owner, location, 1);
staging.push(`Before actual first END Mentat, transfer one Ecaz counter from its reserve to ${location}; no seat controls three holds.`);
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
for (let i = 0; game.turn === 1 && i < 200; i++) {
const next = nextEcazSkillsModulesStep(game); assert.ok(next);
const before = structuredClone(game); game = step(game, next, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
if (stronghold) assert.equal(game.strongholdCards!.owners[strongholdKind], owner);
const afterFirstMentat = structuredClone(game);
let acquisition: EcazSkillsModulesFixture['acquisition'] = null;
if (kind === 'duke') {
game = advanceEcazSkillsModules(game, g => g.phase === 4 && clean(g), actions);
reserveBoard(game);
game = advanceEcazSkillsModules(game, g => g.decision?.kind === 'ecazPlacement', actions);
const before = structuredClone(game), acquisitionActions: StrongholdFactionsNativeStep[] = [];
const token = game.ecazAmbassadors!.tokens.find(t => t.effect === 'ecaz')!;
game = step(game, { actor: owner, action: { type: 'decision', token: token.id, territory: strongholdKind } }, acquisitionActions);
game = advanceEcazSkillsModules(game, g => !g.response && g.decision?.kind === 'ecazPlacement', acquisitionActions);
game = step(game, { actor: owner, action: { type: 'decision', decline: true } }, acquisitionActions);
game = advanceEcazSkillsModules(game, g => g.phase === 5 && clean(g) && g.active === opponent, acquisitionActions);
game = step(game, { actor: opponent, action: { type: 'ship', territory: strongholdKind, sector: territory(strongholdKind).sectors[0], amount: 1 } }, acquisitionActions);
game = advanceEcazSkillsModules(game, g => !g.response && g.decision?.kind === 'ecazAmbassador', acquisitionActions);
assert.equal(viewGame(game, owner).ambassadorEntry!.dukeAcquisition!.blocked, null);
game = step(game, { actor: owner, action: { type: 'decision', event: game.pendingAmbassador!.event,
trigger: true, beneficiary: owner, choice: 'duke' } }, acquisitionActions);
assert.equal(game.dukeVidal!.controller, owner);
actions.push(...acquisitionActions); acquisition = { before, after: structuredClone(game), actions: acquisitionActions };
staging.push('Original paid Ambassador placement and entrant shipment acquire the separate Duke6; no controller assignment or skill reassignment.');
} else game = advanceEcazSkillsModules(game, g => g.phase === 5 && clean(g), actions);
reserveBoard(game);
for (const actor of [owner, opponent]) place(game, actor, location, 8);
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
staging.push(`Before real turn-two Movement completes, conserve eight ordinary counters per combatant at ${location}; return original hands to the same native deck before physical transfers.`);
const worthless = take(game, owner, c => c.kind === 'worthless', staging);
const shield = options.defense ? take(game, owner, c => c.kind === 'shield', staging) : undefined;
const poison = options.poison ? take(game, opponent, c => c.kind === 'poison', staging) : undefined;
game = advanceEcazSkillsModules(game, g => g.phase === 6 && clean(g), actions);
const beforeBattle = structuredClone(game);
assert.ok(game.active === owner || game.active === opponent);
game = step(game, { actor: game.active!, action: { type: 'chooseBattle', territory: strongholdKind,
target: game.active === owner ? opponent : owner } }, actions);
// The owner alone retains the requested visibility; every enemy skill is hidden.
for (let i = 0; !(game.battle && clean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) && i < 200; i++) {
const next = nextEcazSkillsModulesStep(game); assert.ok(next);
if (next.action.type === 'leaderSkillVisibility' && next.actor === owner) next.action.hide = band === 'skilled';
game = step(game, next, actions);
}
const enemy = ecazSkillsModulesPlayer(game, opponent);
const enemyTrainer = ecazSkillsModulesTrainer(game, opponent).leader;
const defender = enemy.leaders.filter(l => !l.dead && l.id !== enemyTrainer).sort((a, b) =>
kind === 'duke' ? b.strength - a.strength : a.strength - b.strength)[0]; assert.ok(defender);
const ownLeader = kind === 'duke' ? DUKE_VIDAL_ID : band === 'skilled' ? trainer
: ecazSkillsModulesPlayer(game, owner).leaders.find(l => l.strength === 4 && l.id !== trainer)!.id;
const ownerDial = kind === 'duke' ? 0 : 5;
const opponentDial = options.opponentDial ?? (kind === 'duke'
? game.battle!.attacker === owner ? 2 : 1 : kind === 'income' ? 1 : 0);
const plans: StrongholdFactionsNativeStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: ownLeader, dial: ownerDial,
support: game.advanced ? ownerDial : 0, weapon: worthless.id, defense: shield?.id } },
{ actor: opponent, action: { type: 'battlePlan', leader: defender.id, dial: opponentDial,
support: game.advanced ? opponentDial : 0, weapon: poison?.id } },
];
assertEcazSkillsModulesCustody(game);
return { initial, offered, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, acquisition,
beforeBattle, game, owner, opponent, trainer, territory: strongholdKind, location, plans, actions, staging };
}
export function revealEcazSkillsModulesBattle(f: EcazSkillsModulesFixture): Game {
let game = reloadEcazSkillsModules(f.game);
for (const plan of f.plans) {
game = step(game, plan);
if (!game.battle?.revealed) game = advanceEcazSkillsModules(game,
g => clean(g) && !g.battle?.preparation && g.battle?.preLeader?.closed !== false);
}
assert.ok(game.battle?.revealed); return game;
}
export function settleEcazSkillsModulesBattle(game: Game, stop: 'suk' | 'cards' | 'tech' | 'complete' = 'complete'): Game {
return advanceEcazSkillsModules(game, g =>
stop === 'suk' && g.decision?.kind === 'sukRescue' ||
stop === 'cards' && g.decision?.kind === 'battleCards' ||
stop === 'tech' && g.decision?.kind === 'techToken' ||
!g.battle && clean(g) && !g.pendingTreacheryDiscard);
}