import assert from 'node:assert/strict'; import { applyAction, createGame, initializeHomeworldOccupationGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine'; import { botActions } from '../game/bots'; import type { Difficulty } from '../game/bot-profiles'; import { territory } from '../game/board'; import { treacheryDeck } from '../game/cards'; import type { FactionId } from '../game/catalog'; import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; import { validateLeaderSkills } from '../game/leader-skills'; import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; import { homeworldForceGroups } from '../game/homeworld-custody'; import { quoteNativeReserveWithdrawal } from '../game/homeworld-native-reserves'; import { homeworldCombatLocation } from '../game/homeworld-combat'; import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; export type ClassicHomeworldSkillsStep = { actor: string; action: Action }; export type ClassicHomeworldSkillsCase = 'native-bonus' | 'visitor-bonus' | 'native-death' | 'visitor-death' | 'native-suk' | 'visitor-suk' | 'arrakis-suk' | 'smuggler-payment'; export type ClassicHomeworldSkillsSetup = { initial: Game; offered: Game; afterSetup: Game; actions: ClassicHomeworldSkillsStep[]; staging: string[]; }; export type ClassicHomeworldSkillsFixture = ClassicHomeworldSkillsSetup & { kind: ClassicHomeworldSkillsCase; beforeShipment: Game | null; shipment: ClassicHomeworldSkillsStep | null; afterShipment: Game | null; beforeBattle: Game | null; game: Game; owner: string; opponent: string; trainer: string; territory: string; plans: ClassicHomeworldSkillsStep[]; }; export const classicHomeworldSkillsPlayer = (game: Game, id: string): Player => { const player = game.players.find(p => p.id === id); assert.ok(player); return player; }; const sum = (values: Record) => Object.values(values).reduce((a, b) => a + b, 0); const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; export function assertClassicHomeworldSkillsCustody(game: Game): void { homeworldGameIntegrity(game); validateLeaderSkills(game.leaderSkills!, game.players); const homes = game.homeworlds?.custody ? homeworldForceGroups(homeworldContext(game), game.homeworlds.custody) : []; for (const player of game.players) { const visitors = homes.filter(home => home.native !== player.id).map(home => home.forces[player.id] ?? { normal: 0, elite: 0 }); assert.equal(player.reserves + player.tanks + sum(player.forces) + visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0), 20, player.faction); if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) + visitors.reduce((n, pool) => n + pool.elite, 0), player.faction === 'emperor' ? 5 : 3, player.faction); } const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), ...(game.auction?.cards.slice(game.auction.index) ?? [])]; assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck([]).map(c => c.id).sort()); } /** Compute actual Fisher-Yates draws, not an assignment or held-card injection. */ function permutationDraws(source: readonly T[], desired: readonly T[]): number[] { const current = [...source], draws: number[] = []; for (let i = current.length - 1; i > 0; i--) { const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); draws.push(Math.floor(((j + 0.5) / (i + 1)) * 4294967296)); [current[i], current[j]] = [current[j], current[i]]; } assert.deepEqual(current, desired); return draws; } function originalLottery(draws: readonly number[], run: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const native = crypto.getRandomValues.bind(crypto); let index = 0; crypto.getRandomValues = (array: V): V => { if (array instanceof Uint32Array && array.length === 1) array[0] = draws[index++] ?? 0xffffffff; else Reflect.apply(native, crypto, [array]); return array; }; try { return run(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } export function classicHomeworldSkillsBotOffers(game: Game, actor: string, difficulty: Difficulty): Action[] { const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty; return botActions(view); } /** Original all-fourteen setup. Only original skill and Treachery shuffle draws * are controlled. Starting hands, wallets, offers and leader custody are dealt * by the original initializer and selected with real setup actions. */ export function createClassicHomeworldSkillsSetup(options: { advanced?: boolean; roster?: readonly FactionId[]; initial?: Game; skill?: LeaderSkillId; occupation?: boolean; } = {}): ClassicHomeworldSkillsSetup { const roster = options.roster ?? ['emperor', 'guild', 'atreides', 'harkonnen']; let game = options.initial ? structuredClone(options.initial) : createGame('CLASSICHWSKILLS', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? false); if (!options.initial) for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.leaderSkills && game.homeworlds && game.turn === 1 && game.phase === 0), 'Continue only an original fresh lobby or its existing Homeworld Skills setup.'); if (game.status === 'lobby') { if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true }); for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' }); } const initial = structuredClone(game), actions: ClassicHomeworldSkillsStep[] = []; const owner = game.players[0].id, skill = options.skill ?? 'mentat'; const canonical = LEADER_SKILL_CARDS.map(c => c.id), requested: LeaderSkillId[] = [skill]; for (const p of game.players.slice(1)) { const preferred: LeaderSkillId = p.faction === 'atreides' ? 'bureaucrat' : p.faction === 'harkonnen' ? 'smuggler' : p.faction === 'guild' ? 'swordmaster-of-ginaz' : p.faction === 'fremen' ? 'killer-medic' : 'prana-bindu-adept'; const selected = requested.includes(preferred) ? (['warmaster', 'planetologist', 'diplomat', 'master-of-assassins', 'sandmaster'] as const).find(id => !requested.includes(id))! : preferred; assert.ok(selected); requested.push(selected); } const rest = canonical.filter(id => !requested.includes(id)); const desired = requested.flatMap(id => [id, rest.shift()!]).concat(rest); const cards = treacheryDeck([]), prefix: string[] = []; for (const [index, player] of game.players.entries()) { const kind = index === 0 ? 'worthless' : index === 1 ? 'projectile' : 'snooper'; const card = cards.find(c => c.kind === kind && !prefix.includes(c.id)) ?? cards.find(c => !prefix.includes(c.id))!; prefix.push(card.id); if (player.faction === 'harkonnen') prefix.push(cards.find(c => !prefix.includes(c.id))!.id); } const deckOrder = [...prefix, ...cards.map(c => c.id).filter(id => !prefix.includes(id))]; if (game.status === 'lobby') game = originalLottery( [...permutationDraws(canonical, desired), ...permutationDraws(cards.map(c => c.id), deckOrder)], () => options.occupation ? initializeHomeworldOccupationGameForAudit(game, true) : initializeLeaderSkillsGameForAudit(game)); let offered: Game | null = null; for (let count = 0; game.status === 'setup' && count < 200; count++) { let next: ClassicHomeworldSkillsStep; if (game.setupStage === 'leaderSkills') { offered ??= structuredClone(game); const actor = Object.keys(game.leaderSkills!.offers)[0]; const view = viewGame(game, actor).leaderSkills!, offer = view.offer!; const selected = requested[game.players.findIndex(p => p.id === actor)]; assert.ok(offer.cards.includes(selected)); assert.ok(!view.unavailableSkills?.[selected]); const leaders = view.eligibleLeaders.map(l => classicHomeworldSkillsPlayer(game, actor).leaders.find(disc => disc.id === l.id)!) .sort((a, b) => b.strength - a.strength); assert.ok(leaders[0]); next = { actor, action: { type: 'leaderSkill', event: offer.event, skill: selected, leader: leaders[0].id } }; } else { const candidate = nextStrongholdFactionsNativeStep(game); assert.ok(candidate); next = candidate; } actions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action); } assert.ok(offered); assert.equal(game.status, 'playing'); assert.ok(game.leaderSkills!.assignments.some(a => a.owner === owner && a.skill === skill)); assertClassicHomeworldSkillsCustody(game); return { initial, offered, afterSetup: structuredClone(game), actions, staging: ['Controlled the original skill/Treachery lottery before any component was dealt; all starting hands and skill assignments are real setup selections.'] }; } export function nextClassicHomeworldSkillsStep(game: Game, hideOwner?: string): ClassicHomeworldSkillsStep | null { const decision = game.decision; if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === hideOwner, } }; if (decision?.kind === 'mentatQuestion') return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } }; if (decision?.kind === 'rihani') return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } }; if (decision?.kind === 'bureaucratPayment') return { actor: decision.player, action: { type: 'decision', event: game.bureaucratPaymentEvent, redirect: false } }; if (decision?.kind === 'sukRescue') return null; if (decision?.kind === 'battleLosses' && game.advanced && game.pendingSukRescue) { const elite = Math.max(...decision.options.map(option => option.elite)); return { actor: decision.player, action: { type: 'decision', choice: decision.options.findIndex(option => option.elite === elite) } }; } if (decision?.kind === 'homeworldDefense') return { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } }; return nextStrongholdFactionsNativeStep(game); } export function advanceClassicHomeworldSkills(state: Game, until: (game: Game) => boolean, actions?: ClassicHomeworldSkillsStep[], hideOwner?: string): Game { let game = state; for (let count = 0; count < 1600; count++) { if (until(game)) return game; const next = nextClassicHomeworldSkillsStep(game, hideOwner); assert.ok(next, 'A real human Battle Plan or Suk choice is required'); actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action); } throw Error('Original classic Homeworld Skills programme did not reach its requested boundary'); } function shipHome(game: Game, actor: string, destination: string, normal: number, elite: number): ClassicHomeworldSkillsStep { const player = classicHomeworldSkillsPlayer(game, actor); const sources = player.faction === 'emperor' && game.advanced ? { 'homeworld:emperor': { normal, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite }, } : { [`homeworld:${player.faction}`]: { normal, elite } }; return { actor, action: { type: 'homeworldShip', event: viewGame(game, actor).homeworldShipment!.event, destination, sources } }; } /** Genuine phase sequence and invasion shipment. The Arrakis case alone uses a * labeled conserved later board position; it never fabricates a battle window, * casualty, death, invoice, skill assignment or held card. */ export function createClassicHomeworldSkillsFixture(options: { kind?: ClassicHomeworldSkillsCase; advanced?: boolean; initial?: Game } = {}): ClassicHomeworldSkillsFixture { const kind = options.kind ?? 'native-bonus', advanced = options.advanced ?? false; const skill = kind.includes('suk') ? 'suk-graduate' : kind === 'smuggler-payment' ? 'smuggler' : 'mentat'; const roster: FactionId[] = ['emperor', 'guild', 'atreides', 'harkonnen']; const setup = createClassicHomeworldSkillsSetup({ advanced, initial: options.initial, roster, skill }); let game = advanceClassicHomeworldSkills(setup.afterSetup, g => g.phase === 5 && clean(g), setup.actions); const owner = game.players[0].id, opponent = game.players[1].id; const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader; const visitor = kind.startsWith('visitor'); const location = kind === 'arrakis-suk' ? 'false_wall_south' : kind === 'smuggler-payment' ? 'sietch_tabr' : visitor ? 'homeworld:guild' : kind === 'native-suk' && advanced ? 'homeworld:emperor:salusa' : 'homeworld:emperor'; let beforeShipment: Game | null = null, shipment: ClassicHomeworldSkillsStep | null = null, afterShipment: Game | null = null; if (kind === 'arrakis-suk') { const count = 8, elite = advanced ? 2 : 0; const sources = advanced ? { 'homeworld:emperor': { normal: count - elite, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite } } : undefined; const quote = quoteNativeReserveWithdrawal(homeworldContext(game), game.homeworlds!.custody!, owner, { normal: count - elite, elite }, sources); game.homeworlds!.custody = quote.state; for (const update of quote.players) { const p = classicHomeworldSkillsPlayer(game, update.id); p.reserves = update.reserves; if (p.elites) p.elites.reserves = update.eliteReserves; } const key = `${location}:${territory(location).sectors[0]}`; classicHomeworldSkillsPlayer(game, owner).forces[key] = count; if (elite) classicHomeworldSkillsPlayer(game, owner).elites!.forces[key] = elite; const other = classicHomeworldSkillsPlayer(game, opponent); const total = sum(other.forces); other.forces = { [key]: total }; setup.staging.push('Conserved later Arrakis position: eight Emperor counters withdrew from their real native homes; the original Guild board army moved to the same desert. No shipment or battle receipt is claimed for these placements.'); assertClassicHomeworldSkillsCustody(game); } else { const actor = kind === 'smuggler-payment' || visitor ? owner : opponent; game = advanceClassicHomeworldSkills(game, g => g.phase === 5 && clean(g) && g.active === actor, setup.actions); beforeShipment = structuredClone(game); shipment = kind === 'smuggler-payment' ? { actor, action: { type: 'ship', territory: location, sector: territory(location).sectors[0], amount: 6, elite: advanced ? 2 : 0, smuggler: true, homeworldSources: advanced ? { 'homeworld:emperor': { normal: 4, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite: 2 } } : undefined } } : shipHome(game, actor, location, visitor ? 4 : 3, visitor && advanced ? 1 : 0); setup.actions.push(structuredClone(shipment)); game = applyAction(game, actor, shipment.action); if (game.decision?.kind === 'guildShipment') { const allow = { actor: game.decision.player, action: { type: 'decision', allow: true } }; setup.actions.push(allow); game = applyAction(game, allow.actor, allow.action); } afterShipment = structuredClone(game); if (kind === 'smuggler-payment') return { ...setup, kind, beforeShipment, shipment, afterShipment, beforeBattle: null, game, owner, opponent, trainer, territory: location, plans: [] }; game = advanceClassicHomeworldSkills(game, g => g.phase === 5 && clean(g), setup.actions); } game = advanceClassicHomeworldSkills(game, g => g.phase === 6 && clean(g), setup.actions); const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === owner || actor === opponent); const choose = { actor, action: { type: 'chooseBattle', territory: location, target: actor === owner ? opponent : owner } }; setup.actions.push(choose); game = applyAction(game, actor, choose.action); game = advanceClassicHomeworldSkills(game, g => !!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, setup.actions, owner); const enemy = classicHomeworldSkillsPlayer(game, opponent); const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader)); const leader = enemy.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(leader); const weapon = kind.endsWith('death') ? enemy.hand.find(c => c.kind === 'projectile')?.id : undefined; if (kind.endsWith('death')) assert.ok(weapon, 'Actual original opponent starting hand holds the lethal weapon'); const dial = kind.endsWith('death') ? 0 : kind === 'native-suk' && advanced ? 6 : 4; const plans: ClassicHomeworldSkillsStep[] = [ { actor: owner, action: { type: 'battlePlan', leader: trainer, dial, support: advanced && dial && kind !== 'native-suk' ? 2 : 0 } }, { actor: opponent, action: { type: 'battlePlan', leader: leader.id, dial: 0, weapon } }, ]; assertClassicHomeworldSkillsCustody(game); return { ...setup, kind, beforeShipment, shipment, afterShipment, beforeBattle, game, owner, opponent, trainer, territory: location, plans }; } export function revealClassicHomeworldSkillsBattle(fixture: ClassicHomeworldSkillsFixture): Game { let game = structuredClone(fixture.game); for (const step of fixture.plans) { game = applyAction(game, step.actor, step.action); if (!game.battle?.revealed) game = advanceClassicHomeworldSkills(game, g => clean(g) && !!g.battle && !g.battle.preparation, undefined, fixture.owner); } assert.ok(game.battle?.revealed); return game; } export function settleClassicHomeworldSkillsBattle(state: Game, stop: 'suk' | 'complete' = 'complete'): Game { return advanceClassicHomeworldSkills(state, game => stop === 'suk' && game.decision?.kind === 'sukRescue' || !game.battle && clean(game) && !game.pendingTreacheryDiscard); } export function quoteClassicHomeworldSkillsBattle(game: Game) { const battle = game.battle; assert.ok(battle?.revealed); const side = (actor: string): ResolutionCombatant => { const player = classicHomeworldSkillsPlayer(game, actor), view = viewGame(game, actor); return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand, plan: battle.plans[actor], leader: player.leaders.find(l => l.id === battle.plans[actor].leader), forces: view.battle!.ownForces!, leaderSkills: view.leaderSkills!.assignments.filter(a => a.controller === actor), occupiedStrongholds: new Set(Object.keys(player.forces).map(key => key.split(':')[0]).filter(id => territory(id).type === 'stronghold')).size }; }; const home = battle.territory.startsWith('homeworld:') ? homeworldCombatLocation({ ...homeworldContext(game), order: game.order }, game.homeworlds!.custody!, battle.territory) : null; return quoteBattleResolution({ advanced: game.advanced, typedCasualties: true, turn: game.turn, territory: battle.territory, aggressor: viewGame(game, game.host).battle!.aggressor, ...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}), attacker: side(battle.attacker), defender: side(battle.defender), voters: viewGame(game, game.host).battle!.traitorVoters.map(id => ({ id, beneficiary: id, called: battle.traitorCalls[id] ?? false, traitors: classicHomeworldSkillsPlayer(game, id).traitors })), participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)], pendingAuditorPresent: false, pendingRetentionPresent: false }); }