import assert from 'node:assert/strict'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game, } from '../game/engine'; import type { Difficulty } from '../game/bot-profiles'; import { botActions } from '../game/bots'; import type { FactionId } from '../game/catalog'; import type { Card } from '../game/cards'; import { TERRITORIES, territory } from '../game/board'; import { ownedTech } from '../game/tech-tokens'; import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills'; import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds'; import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; export type MoritaniSkillsModulesOptions = { initial?: Game; rules?: 'basic' | 'advanced'; tech?: boolean; strongholds?: boolean; holder?: 'moritani' | 'guild'; normalCall?: boolean; }; export type MoritaniSkillsModulesFixture = { initial: Game; afterSetup: Game; afterFirstStorm: Game; beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game; beforeShipment: Game; shipmentStep: StrongholdFactionsNativeStep; afterShipment: Game; afterMovement: Game; beforeBattle: Game; game: Game; revealed: Game; pending: Game; moritani: string; opponent: string; holder: string; target: string; moritaniLeader: string; opponentLeader: string; moritaniCards: string[]; opponentCards: string[]; battleAction: StrongholdFactionsNativeStep; planActions: StrongholdFactionsNativeStep[]; sourceActions: StrongholdFactionsNativeStep[]; staging: string[]; }; export function moritaniSkillsModulesPlayer(game: Game, id: string) { const player = game.players.find(p => p.id === id); assert.ok(player, `Missing original seat ${id}`); return player; } export function moritaniSkillsModulesPolicy(game: Game, actor: string, difficulty: Difficulty = 'Easy') { const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty; return botActions(view); } /** A response pass queue takes precedence over every simultaneous native choice. * A null result is the genuine boundary requiring a human sealed Battle Plan. */ export function nextMoritaniSkillsModulesStep(game: Game): StrongholdFactionsNativeStep | null { if (game.response || game.phaseOpening || game.pendingTreacheryDiscard) return nextStrongholdFactionsNativeStep(game); const d = game.decision; if (d?.kind === 'leaderSkillVisibility') return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: false } }; if (d?.kind === 'mentatQuestion') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } }; if (d?.kind === 'moritaniSetup') { const action = moritaniSkillsModulesPolicy(game, d.player)[0]; assert.ok(action); return { actor: d.player, action }; } if (d?.kind === 'moritaniPlacement') return { actor: d.player, action: { type: 'decision', decline: true } }; if (d?.kind === 'moritaniAssassinate') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } }; if (d?.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } }; return nextStrongholdFactionsNativeStep(game); } function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]) { actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action); } export function advanceMoritaniSkillsModules(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[] = []): Game { for (let count = 0; count < 2000; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished'); const next = nextMoritaniSkillsModulesStep(game); assert.ok(next, 'A real original sealed Battle Plan is required'); game = run(game, next, actions); } throw Error('Original Moritani skills/modules continuation did not reach its requested boundary'); } export const moritaniSkillsModulesClean = (game: Game) => !game.response && !game.decision && !game.phaseOpening; /** Native offers and starting wallets; never convert a played save or redeal a * supplied original setup. Lobby modules are enabled by their real host actions. */ export function initializeMoritaniSkillsModulesOffers(options: MoritaniSkillsModulesOptions = {}): Game { const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true; const tech = options.tech ?? true; const strongholds = options.strongholds ?? advanced; let game: Game; if (options.initial) game = structuredClone(options.initial); else { game = createGame('MORITANISKILLSMODULES', newPlayer('moritani', 'Moritani', 'moritani'), advanced, ['ecaz']); joinGame(game, newPlayer('guild', 'Guild', 'guild')); joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); } assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']); assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6); assert.equal(game.players.filter(p => p.faction === 'moritani').length, 1); assert.equal(game.players.filter(p => p.faction === 'guild').length, 1); const allowed: FactionId[] = ['moritani', 'atreides', 'emperor', 'fremen', 'guild', 'beneGesserit']; assert.ok(game.players.every(p => allowed.includes(p.faction))); assert.ok(tech || strongholds); assert.ok(!strongholds || advanced); if (game.status === 'lobby') { if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech }); if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds }); } else { assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds); } game = initializeAdvancedNativeSkillsSetup({ family: 'moritani', skillOwner: 'guild', requestedSkill: 'suk-graduate', rules: advanced ? 'advanced' : 'basic', initial: game }); while (game.setupStage === 'prediction') { const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); game = applyAction(game, next.actor, next.action); } return game; } export function completeMoritaniSkillsModulesSetup(state: Game): Game { let game = state; const guild = game.players.find(p => p.faction === 'guild')!.id; for (let count = 0; game.status === 'setup' && count < 200; count++) { if (game.setupStage === 'leaderSkills') { const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!; const offer = game.leaderSkills!.offers[actor]; const skill = actor === guild ? 'suk-graduate' : offer.cards.find(c => !own.unavailableSkills?.[c]); const leader = actor === guild ? own.eligibleLeaders.find(l => l.name === 'Master Bewt') : own.eligibleLeaders[0]; assert.ok(skill && offer.cards.includes(skill) && leader); game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: leader.id }); } else { const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); game = applyAction(game, next.actor, next.action); } } assert.equal(game.status, 'playing'); return game; } function reserveBoard(game: Game) { for (const p of game.players) { p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {}; if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); p.elites.forces = {}; } if (p.advisors) p.advisors = {}; } } function place(game: Game, actor: string, location: string, count: number) { const p = moritaniSkillsModulesPlayer(game, actor); assert.ok(p.reserves >= count); p.reserves -= count; p.forces[location] = count; } function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[], excluded: string[] = []) { const p = moritaniSkillsModulesPlayer(game, actor); const held = p.hand.find(c => c.kind === kind && !excluded.includes(c.id)); if (held) return held.id; const index = game.deck.findIndex(c => c.kind === kind && !excluded.includes(c.id)); assert.ok(index >= 0); assert.ok(p.hand.length < 4); const [card] = game.deck.splice(index, 1); p.hand.push(card); staging.push(`Conserved native ecaz33 ${card.id}: remaining deck → ${actor}'s hand`); return card.id; } export function stageMoritaniSkillsModulesBattle(game: Game, moritani: string, opponent: string, staging: string[] = []) { assert.equal(game.phase, 5); assert.ok(moritaniSkillsModulesClean(game)); reserveBoard(game); const location = `tueks_sietch:${territory('tueks_sietch').sectors[0]}`; place(game, moritani, location, 6); place(game, opponent, location, 6); staging.push('Before actual movement completion: relocate six original counters per combatant through own reserves; no currency, storm, phase, turn or card-owner edits.'); return game; } export function revealMoritaniSkillsModulesPlans(game: Game, plans: StrongholdFactionsNativeStep[], actions: StrongholdFactionsNativeStep[] = []): Game { for (const plan of plans) { game = run(game, plan, actions); while (game.response || game.decision?.kind === 'fullPlanOffer') { const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); game = run(game, next, actions); } } assert.ok(game.battle?.revealed); return game; } /** Stop BEFORE original Suk rescue or original assassination; do not erase the * winner's casualties, card decisions or saved continuation to reach a choice. */ export function resolveMoritaniSkillsModulesBattle(game: Game, moritani: string, normalCall = false, actions: StrongholdFactionsNativeStep[] = []): Game { for (let count = 0; count < 160; count++) { if (game.decision?.kind === 'sukRescue' || game.decision?.kind === 'moritaniAssassinate' || !game.battle && moritaniSkillsModulesClean(game) && !game.pendingTreacheryDiscard) return game; const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani; game = run(game, next, actions); } throw Error('Original Moritani skills/modules battle suffix did not settle'); } export function createMoritaniSkillsModulesFixture(options: MoritaniSkillsModulesOptions = {}): MoritaniSkillsModulesFixture { const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = []; let game = initializeMoritaniSkillsModulesOffers(options); const initial = structuredClone(game); game = completeMoritaniSkillsModulesSetup(game); const afterSetup = structuredClone(game); const moritani = game.players.find(p => p.faction === 'moritani')!.id; const opponent = game.players.find(p => p.faction === 'guild')!.id; const holder = options.holder === 'moritani' ? moritani : opponent; while (game.phase === 0) { const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); if (next.action.type === 'stormDial') next.action.amount = 4; game = run(game, next, actions); } const afterFirstStorm = structuredClone(game); game = advanceMoritaniSkillsModules(game, s => s.phase === 8 && moritaniSkillsModulesClean(s), actions); reserveBoard(game); place(game, holder, `tueks_sietch:${territory('tueks_sietch').sectors[0]}`, 1); place(game, holder === moritani ? opponent : moritani, `carthag:${territory('carthag').sectors[0]}`, 1); staging.push('Before real first END Mentat: conserved native counters, one controlled stronghold each; card ownership remains entirely engine-derived.'); let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined; while (game.turn === 1) { const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); const before = structuredClone(game); game = run(game, next, actions); if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } } assert.ok(beforeFirstMentat && firstMentatStep); const afterFirstMentat = structuredClone(game); game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s), actions); stageMoritaniSkillsModulesBattle(game, moritani, opponent, staging); const m = moritaniSkillsModulesPlayer(game, moritani), guild = moritaniSkillsModulesPlayer(game, opponent); const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader)); const ownLeader = m.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; const enemyLeader = guild.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; const target = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader; assert.ok(ownLeader && enemyLeader && enemyLeader.id !== target); holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemyLeader.id : target, staging); const moritaniCards = [heldCard(game, moritani, 'worthless', staging)]; moritaniCards.push(heldCard(game, moritani, 'worthless', staging, moritaniCards)); const opponentCards = [heldCard(game, opponent, 'worthless', staging)]; // Empty one selected battle deposit by returning it to the unoccupied Polar // Sink. This is explicit conserved board-spice staging, not minted bank money. const location = `tueks_sietch:${territory('tueks_sietch').sectors[0]}`; const deposit = game.spice[location] ?? 0; if (deposit) { delete game.spice[location]; game.spice['polar_sink:0'] = (game.spice['polar_sink:0'] ?? 0) + deposit; } staging.push(`Conserved board deposit ${deposit}: battle sector → unoccupied Polar Sink; isolate real battle/income wallet legs.`); // One actual non-Guild shipment triggers printed Heighliners accrual. Keep // native active/order/shipped fields intact and reach Moritani by actions. game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && s.active === moritani && moritaniSkillsModulesClean(s), actions); const destination = TERRITORIES.find(site => site.type === 'sand' && !site.sectors.includes(game.storm) && !Object.entries(game.spice).some(([key, count]) => count > 0 && key.startsWith(`${site.id}:`)) && game.players.every(p => !Object.entries(p.forces).some(([key, count]) => count > 0 && key.startsWith(`${site.id}:`)))); assert.ok(destination, 'The original invoice needs an empty, spice-free desert destination.'); const beforeShipment = structuredClone(game); const shipmentStep: StrongholdFactionsNativeStep = { actor: moritani, action: { type: 'ship', territory: destination.id, sector: destination.sectors[0], amount: 1 } }; game = run(game, shipmentStep, actions); game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s) && !s.pendingShipment, actions); const afterShipment = structuredClone(game); game = advanceMoritaniSkillsModules(game, s => s.phase === 6 && moritaniSkillsModulesClean(s), actions); const afterMovement = structuredClone(game), beforeBattle = structuredClone(game); const actor = game.active!; assert.ok([moritani, opponent].includes(actor)); const battleAction: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === moritani ? opponent : moritani } }; game = run(game, battleAction, actions); game = advanceMoritaniSkillsModules(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniSkillsModulesStep(s), actions); const planActions: StrongholdFactionsNativeStep[] = [ { actor: moritani, action: { type: 'battlePlan', leader: ownLeader.id, dial: 1, ...(game.advanced ? { support: 1 } : {}), weapon: moritaniCards[0], defense: moritaniCards[1] } }, { actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 3, ...(game.advanced ? { support: 3 } : {}), weapon: opponentCards[0] } }, ]; const prepared = structuredClone(game), revealed = revealMoritaniSkillsModulesPlans(game, planActions, actions); const pending = resolveMoritaniSkillsModulesBattle(structuredClone(revealed), moritani, options.normalCall, actions); // Three-seat originals get one token per seat; larger originals must not // fabricate missing ownership merely to offer a post-battle Tech choice. if (afterFirstStorm.techTokens && afterFirstStorm.players.length === 3) assert.equal(ownedTech(afterFirstStorm.techTokens, opponent).length, 1); return { initial, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeShipment, shipmentStep, afterShipment, afterMovement, beforeBattle, game: prepared, revealed, pending, moritani, opponent, holder, target, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id, moritaniCards, opponentCards, battleAction, planActions, sourceActions: actions, staging }; } export function rejectMoritaniSkillsModulesAction(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); }