import assert from 'node:assert/strict'; import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, } from '../game/engine'; import type { FactionId } from '../game/catalog'; import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries'; import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income'; import { createStrongholdCards } from '../game/stronghold-cards'; export type ClassicDiscoverySkillsStep = { actor: string; action: Action }; export type ClassicDiscoverySkillsOptions = { /** Authenticated fresh lobby or original staged setup; never a played save. */ initial?: Game; advanced?: boolean; tech?: boolean; /** Preserve the selected, still-unused original Advanced module at setup. */ strongholdCards?: boolean; seats?: 2 | 3 | 4 | 5 | 6; collector?: 'guild' | 'harkonnen'; skill?: LeaderSkillId; skillOwner?: 'guild' | 'harkonnen' | 'emperor'; face?: DiscoveryTokenFace; amount?: number; }; export type ClassicDiscoverySkillsFixture = { initial: Game; setup: Game; game: Game; actions: ClassicDiscoverySkillsStep[]; staging: string[]; collector: string; opponent: string; leader: string; token: string; face: DiscoveryTokenFace; parent: string; parentSector: number; }; export const classicDiscoverySkillsClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard; export function nextClassicDiscoverySkillsStep(game: Game): ClassicDiscoverySkillsStep { if (game.decision?.kind === 'discoveryEntry') return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, accept: false } }; if (game.battle?.preLeader?.closed === false && !game.response && !game.decision && !game.battle.preparation) { const frame = game.battle.preLeader; return { actor: frame.ready.includes(game.battle.attacker) ? game.battle.defender : game.battle.attacker, action: { type: 'battlePreparationReady', event: frame.event } }; } const next = nextSpiceBankerIncomeNativeStep(game); assert.ok(next, 'The original classic program needs a live owned continuation.'); return next; } export function advanceClassicDiscoverySkills(state: Game, until: (game: Game) => boolean, actions?: ClassicDiscoverySkillsStep[]): Game { let game = state; for (let i = 0; !until(game) && i < 1600; i++) { assert.notEqual(game.status, 'finished'); const next = nextClassicDiscoverySkillsStep(game); actions?.push(next); game = applyAction(game, next.actor, next.action); } assert.ok(until(game), 'The connected classic program did not reach its original action window.'); return game; } /** Physical offer lottery only: each original seat receives a source-clear skill. * The rest of setup entropy, including Treachery and Traitor cards, stays real. */ function withSkillOffers(skills: LeaderSkillId[], operation: () => T): T { const original = crypto.getRandomValues.bind(crypto); const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const current = LEADER_SKILL_CARDS.map(card => card.id) as LeaderSkillId[]; const desired = Array(current.length); skills.forEach((skill, index) => { desired[index * 2] = skill; }); const rest = current.filter(skill => !skills.includes(skill)); for (let i = 0; i < desired.length; i++) if (!desired[i]) desired[i] = rest.shift()!; let index = current.length - 1; crypto.getRandomValues = (array: V): V => { if (index < 1) return Reflect.apply(original, crypto, [array]) as V; assert.ok(array instanceof Uint32Array && array.length === 1); const swap = current.indexOf(desired[index]); assert.ok(swap <= index); array[0] = Math.floor((swap + 0.5) / (index + 1) * 0x100000000); [current[index], current[swap]] = [current[swap], current[index]]; index--; return array; }; try { return operation(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } function withDiscoveryPlacement(game: Game, face: DiscoveryTokenFace, operation: () => T): T { const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type); const index = eligible.findIndex(token => token.face === face); assert.ok(index >= 0); const original = crypto.getRandomValues.bind(crypto); const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); crypto.getRandomValues = (array: V): V => { if (array instanceof Uint32Array && array.length === 1) array[0] = Math.floor((index + 0.5) / eligible.length * 0x100000000); else Reflect.apply(original, crypto, [array]); return array; }; try { return operation(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } /** Original base33/all14 staged setup, original Storm/Bidding/Shipment/Collection. * Explicit positions: actual skill offers, four unplayed Spice Cards and supply * token lottery. Never stage clocks, phases, wallets, forces or pending frames. */ export function createClassicDiscoverySkillsFixture(options: ClassicDiscoverySkillsOptions = {}): ClassicDiscoverySkillsFixture { const collectorFaction = options.collector ?? 'guild', requested = options.skill ?? 'planetologist'; const face = options.face ?? 'cistern'; let game: Game; if (options.initial) game = structuredClone(options.initial); else { const factions = [collectorFaction, 'emperor', collectorFaction === 'guild' ? 'harkonnen' : 'guild', 'atreides', 'fremen', 'beneGesserit'] as FactionId[]; game = createGame('CLASSICDISCOVERYSKILLS', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? false); for (const faction of factions.slice(1, options.seats ?? 3)) joinGame(game, newPlayer(faction, faction, faction)); } assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0)); const controlledOffers = game.status === 'lobby'; assert.deepEqual(game.expansions, []); assert.ok(!game.homeworlds && !game.nexusCards); if (options.strongholdCards || game.strongholdCards) { assert.equal(game.advanced, true); if (!game.strongholdCards) { assert.equal(game.status, 'lobby', 'Strongholds must be selected before the original deal.'); game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); } assert.deepEqual(game.strongholdCards, createStrongholdCards(), 'Never accept an earned or injected held-card receipt.'); } const collector = game.players.find(player => player.faction === collectorFaction)!.id; const opponent = game.players.find(player => player.faction === 'emperor')!.id; const skillOwner = game.players.find(player => player.faction === (options.skillOwner ?? collectorFaction))!.id; const choices = (['planetologist', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz', 'warmaster', 'master-of-assassins', 'bureaucrat'] as LeaderSkillId[]).filter(skill => skill !== requested); const skills = game.players.map(player => player.id === skillOwner ? requested : choices.shift()!); const actions: ClassicDiscoverySkillsStep[] = []; if (game.status === 'lobby') { if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); game.discoveryEnabled = true; for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); } const initial = structuredClone(game); if (game.status === 'lobby') game = withSkillOffers(skills, () => initializeLeaderSkillsGameForAudit(game)); assert.ok(game.discoveryEnabled && game.discoveries && game.leaderSkills); const setup = structuredClone(game); for (let i = 0; game.status === 'setup' && i < 200; i++) { if (game.setupStage !== 'leaderSkills') { const next = nextClassicDiscoverySkillsStep(game); actions.push(next); game = applyAction(game, next.actor, next.action); continue; } const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor]; const view = viewGame(game, actor).leaderSkills!; const skill = actor === skillOwner ? requested : offer.cards.find(card => !['spice-banker', 'mentat', 'diplomat'].includes(card) && !view.unavailableSkills?.[card]); assert.ok(skill && offer.cards.includes(skill), 'Authenticated offer must contain the requested source-clear skill.'); const eligible = view.eligibleLeaders.map(candidate => game.players.find(player => player.id === actor)!.leaders.find(leader => leader.id === candidate.id)!); const leader = eligible.sort((a, b) => b.strength - a.strength)[0]; assert.ok(leader); const next: ClassicDiscoverySkillsStep = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }; actions.push(next); game = applyAction(game, actor, next.action); } assert.equal(game.status, 'playing'); const leader = game.leaderSkills!.assignments.find(assignment => assignment.owner === collector)!.leader; const printed = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north'; const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed); assert.ok(index >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]); for (let position = 1; position < 4; position++) { const ordinary = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery); assert.ok(ordinary >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]); } const staging = [controlledOffers ? 'Selected original physical all14 offer lottery; reordered four original unplayed Spice Cards; selected original supply-token lottery.' : 'Kept actual already-dealt Skill offers and assignments; reordered four original unplayed Spice Cards; selected original supply-token lottery.']; game = advanceClassicDiscoverySkills(game, state => state.phase === 1 && classicDiscoverySkillsClean(state), actions); game = withDiscoveryPlacement(game, face, () => advanceClassicDiscoverySkills(game, state => state.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions)); game = advanceClassicDiscoverySkills(game, state => state.phase === 5 && state.active === collector && classicDiscoverySkillsClean(state), actions); const placement = DISCOVERY_CARD_PLACEMENTS[printed]; const shipment: ClassicDiscoverySkillsStep = { actor: collector, action: { type: 'ship', territory: placement.territory, sector: placement.sector, amount: options.amount ?? 3, elite: 0, allyPayment: 0 } }; actions.push(shipment); game = applyAction(game, collector, shipment.action); game = advanceClassicDiscoverySkills(game, state => state.phase === 7 && classicDiscoverySkillsClean(state), actions); const token = game.discoveries!.tokens.find(candidate => candidate.face === face)!; assert.equal(game.turn, 1); const offer = viewGame(game, collector).discoveries!; assert.ok(offer.canInspect.includes(token.id) || offer.canReveal.includes(token.id)); return { initial, setup, game, actions, staging, collector, opponent, leader, token: token.id, face, parent: placement.territory, parentSector: placement.sector }; } export function revealClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture, actions?: ClassicDiscoverySkillsStep[]): Game { let game = fixture.game; if (viewGame(game, fixture.collector).discoveries!.canInspect.some(token => token === fixture.token)) { const inspect: ClassicDiscoverySkillsStep = { actor: fixture.collector, action: { type: 'discovery', token: fixture.token, reveal: false } }; actions?.push(inspect); game = applyAction(game, inspect.actor, inspect.action); } const reveal: ClassicDiscoverySkillsStep = { actor: fixture.collector, action: { type: 'discovery', token: fixture.token, reveal: true } }; actions?.push(reveal); return applyAction(game, reveal.actor, reveal.action); } export function classicDiscoverySkillsEntryWindow(fixture: ClassicDiscoverySkillsFixture): Game { return advanceClassicDiscoverySkills(revealClassicDiscoverySkills(fixture), game => game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.collector); } export function enterClassicDiscoverySkills(fixture: ClassicDiscoverySkillsFixture): Game { const game = classicDiscoverySkillsEntryWindow(fixture), view = viewGame(game, fixture.collector); const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action); return applyAction(game, fixture.collector, action); } export function classicDiscoverySkillsShipmentWindow(state: Game, owner: string): Game { return advanceClassicDiscoverySkills(state, game => game.turn === 2 && game.phase === 5 && game.active === owner && classicDiscoverySkillsClean(game)); } export function settleClassicDiscoverySkillsArrival(state: Game): Game { return advanceClassicDiscoverySkills(state, game => !game.pendingShipment && classicDiscoverySkillsClean(game)); } /** Original two-turn nested battle: collector enters free, Emperor ships one * physical normal force at the printed nested rate, then original battle setup. */ export function openClassicDiscoverySkillsBattle(fixture: ClassicDiscoverySkillsFixture, hide = true): Game { let game = classicDiscoverySkillsShipmentWindow(enterClassicDiscoverySkills(fixture), fixture.opponent); game = applyAction(game, fixture.opponent, { type: 'ship', territory: fixture.face, sector: 0, amount: 1, elite: 0, allyPayment: 0 }); game = advanceClassicDiscoverySkills(game, state => state.phase === 6 && !!state.active && classicDiscoverySkillsClean(state)); const actor = game.active!; assert.ok(actor === fixture.collector || actor === fixture.opponent); game = applyAction(game, actor, { type: 'chooseBattle', territory: fixture.face, target: actor === fixture.collector ? fixture.opponent : fixture.collector }); for (let i = 0; (game.response || game.decision || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) { if (game.decision?.kind === 'leaderSkillVisibility') game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: game.decision.player === fixture.collector && hide }); else { const next = nextClassicDiscoverySkillsStep(game); game = applyAction(game, next.actor, next.action); } } assert.ok(game.battle && !game.response && !game.decision); return game; }