import assert from 'node:assert/strict'; import { applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player, } from '../game/engine'; import { TERRITORIES } from '../game/board'; import type { Card } from '../game/cards'; import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries'; import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; import { nextSkillsTechBattleStep, openSkillsTechBattle, finishSkillsTechBattle } from './fixture-skills-tech-battle'; export type DiscoveryNativeE2SkillsStep = { actor: string; action: Action }; export type DiscoveryNativeE2SkillsOptions = { /** Authenticated fresh lobby or its original undealt, unassigned setup. */ initial?: Game; advanced?: boolean; tech?: boolean; strongholds?: boolean; kind?: 'stash' | 'invoice' | 'marker'; marker?: 0 | 3 | 5; reserveCap?: number; /** Human native-mechanics path: retain native random shuffle and select only * real available offers. Specific trained consumers require their actual skill. */ useActualOffers?: boolean; }; export type DiscoveryNativeE2SkillsFixture = { initial: Game; setup: Game; afterSetup: Game; afterFirstStorm: Game; game: Game; actions: DiscoveryNativeE2SkillsStep[]; staging: string[]; kind: 'stash' | 'invoice' | 'marker'; richese: string; choam: string; guild: string; observer: string; collector: string; token: string; face: DiscoveryTokenFace; parent: string; parentSector: number; trainers: { richese: string; choam: string }; green: string; }; export type DiscoveryNativeE2SkillsCheckpoint = { beforeFirstMentat: Game; firstMentatStep: DiscoveryNativeE2SkillsStep; afterFirstMentat: Game; game: Game; }; export type DiscoveryNativeE2MarkerBattle = { beforeShipment: Game; shipmentStep: DiscoveryNativeE2SkillsStep; afterShipment: Game; game: Game; token: string; value: 0 | 3 | 5; materialized: number; key: string; ownerPlan: Action; opponentPlan: Action; }; export const discoveryNativeE2SkillsClean = (game: Game) => !game.response && !game.decision && !game.phaseOpening; export function discoveryNativeE2SkillsPlayer(game: Game, actor: string): Player { const player = game.players.find(p => p.id === actor); assert.ok(player); return player; } const greenId = (game: Game) => game.advanced ? 'richese-residual-poison' : 'richese-nullentropy-box'; /** Control only the original all14 shuffle, never an existing offer or later shuffle. */ function withOffers(selected: LeaderSkillId[], operation: () => T): T { const working = LEADER_SKILL_CARDS.map(card => card.id); const remaining = working.filter(card => !selected.includes(card)); const desired = selected.flatMap(card => [card, remaining.shift()!]); desired.push(...remaining); const rolls: number[] = []; for (let i = working.length - 1; i > 0; i--) { const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1))); [working[i], working[j]] = [working[j], working[i]]; } const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const original = crypto.getRandomValues.bind(crypto); let cursor = 0; crypto.getRandomValues = (array: V): V => { if (cursor < rolls.length) { assert.ok(array instanceof Uint32Array && array.length === 1); array[0] = rolls[cursor++]; } else Reflect.apply(original, crypto, [array]); return array; }; try { return operation(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } function requestedSkill(faction: string): LeaderSkillId | undefined { return faction === 'richese' ? 'planetologist' : faction === 'choam' ? 'smuggler' : faction === 'emperor' ? 'bureaucrat' : undefined; } /** Native no-call/no-spend continuation; acquire the first actual green cache lot. * Later unbid lots are removed normally, not counted as an exhausted-cache success. */ export function nextDiscoveryNativeE2SkillsStep(game: Game, useActualOffers = false): DiscoveryNativeE2SkillsStep { if (game.status === 'setup' && game.setupStage === 'leaderSkills') { const actor = Object.keys(game.leaderSkills!.offers)[0]; const offer = game.leaderSkills!.offers[actor], view = viewGame(game, actor).leaderSkills!; const player = discoveryNativeE2SkillsPlayer(game, actor); const requested = requestedSkill(player.faction); const skill = useActualOffers ? offer.cards.find(card => card === requested && !view.unavailableSkills?.[card]) ?? offer.cards.find(card => !view.unavailableSkills?.[card]) : requested ?? offer.cards.find(card => !view.unavailableSkills?.[card]); const eligible = new Set(view.eligibleLeaders.map(leader => leader.id)); const leader = player.leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; assert.ok(skill && offer.cards.includes(skill) && !view.unavailableSkills?.[skill] && leader, 'The original offer must contain the requested native consumer skill; never replace an existing offer.'); return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }; } if (!game.response && !game.phaseOpening && game.decision) { const d = game.decision, actor = d.player, event = game.richeseBidding?.event; if (d.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } }; if (d.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } }; if (d.kind === 'richeseCache') { const card = game.richeseCache!.find(c => c.id === greenId(game)) ?? game.richeseCache![0]; assert.ok(card, 'The original cache must still have a physical lot.'); return { actor, action: { type: 'decision', event, card: card.id, method: 'onceAround', direction: 'counterclockwise' } }; } if (d.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: game.richeseAuction?.cardId === greenId(game) } }; if (d.kind === 'discoveryEntry') return { actor, action: { type: 'decision', event: d.event, accept: false } }; } const lot = game.richeseAuction; if (discoveryNativeE2SkillsClean(game) && lot && !lot.outcome) { const actor = lot.method === 'silent' ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active; assert.ok(actor); return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } }; } return nextSkillsTechBattleStep(game); } export function stepDiscoveryNativeE2Skills(game: Game, step: DiscoveryNativeE2SkillsStep, actions?: DiscoveryNativeE2SkillsStep[]): Game { actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action); } export function advanceDiscoveryNativeE2Skills(game: Game, until: (state: Game) => boolean, actions?: DiscoveryNativeE2SkillsStep[]): Game { for (let n = 0; n < 1800; n++) { if (until(game)) return game; assert.notEqual(game.status, 'finished'); game = stepDiscoveryNativeE2Skills(game, nextDiscoveryNativeE2SkillsStep(game), actions); } throw Error('Original E2 Discovery/Skills policy did not reach its requested window.'); } export function completeDiscoveryNativeE2SkillsSetup(game: Game, useActualOffers = false, actions?: DiscoveryNativeE2SkillsStep[]): Game { for (let n = 0; game.status === 'setup' && n < 200; n++) game = stepDiscoveryNativeE2Skills(game, nextDiscoveryNativeE2SkillsStep(game, useActualOffers), actions); assert.equal(game.status, 'playing'); return game; } export function settleDiscoveryNativeE2Skills(game: Game, actions?: DiscoveryNativeE2SkillsStep[]): Game { return advanceDiscoveryNativeE2Skills(game, state => discoveryNativeE2SkillsClean(state) && !state.pendingShipment && !state.pendingTreacheryDiscard && !state.pendingChoamWorthless, actions); } export function holdDiscoveryNativeE2SkillsCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[] = []): Card { const p = discoveryNativeE2SkillsPlayer(game, actor), held = p.hand.find(matches); if (held) return held; assert.ok(p.hand.length < handLimit(p)); const at = game.deck.findIndex(matches); let card: Card; if (at >= 0) card = game.deck.splice(at, 1)[0]; else { const donor = game.players.find(other => other.id !== actor && other.hand.some(matches)); if (donor) { card = donor.hand.splice(donor.hand.findIndex(matches), 1)[0]; staging.push(`Conserved original ${card.id}: ${donor.faction} held position to ${p.faction}; no earned sale or payment claimed.`); } else { const discarded = game.discard.findIndex(matches); assert.ok(discarded >= 0, 'The original physical ordinary card must exist in current deck, hand or discard custody.'); card = game.discard.splice(discarded, 1)[0]; staging.push(`Controlled conserved original ${card.id}: actual public discard position to ${p.faction}'s hand; this is not an earned draw or a natural card history.`); } } p.hand.push(card); if (at >= 0) staging.push(`Conserved original ${card.id}: undealt ordinary deck to ${p.faction} hand.`); return card; } function withPlacement(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 descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const original = crypto.getRandomValues.bind(crypto); crypto.getRandomValues = (array: V): V => { if (array instanceof Uint32Array && array.length === 1) array[0] = Math.floor((index + 0.5) * 0x100000000 / eligible.length); else Reflect.apply(original, crypto, [array]); return array; }; try { return operation(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } /** Human path: original ready lobby → all14 choices → Traitor choices → first * Storm → Discovery blow → zero-bid green cache acquisition → collector ships * one real force → Collection. Stops BEFORE inspect/reveal. No wallets, phases, * receipts, offers, Tech owners or paid marker histories are assigned. */ export function createDiscoveryNativeE2SkillsFixture(options: DiscoveryNativeE2SkillsOptions = {}): DiscoveryNativeE2SkillsFixture { const kind = options.kind ?? 'marker'; let game = options.initial ? structuredClone(options.initial) : createGame('DISCOVERYNATIVEE2SKILLS', newPlayer('richese', 'Richese', 'richese'), options.advanced ?? false, ['choam']); if (!options.initial) for (const faction of ['choam', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); assert.deepEqual(game.expansions, ['choam']); if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); assert.ok(!game.homeworlds && !game.nexusCards); assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.leaderSkills?.assignments.length === 0 && game.players.every(p => p.hand.length <= (p.faction === 'harkonnen' ? 2 : 1) && p.traitors.length === 0 && p.traitorChoices.length === 0), 'Only a fresh lobby or its original unassigned skill setup can enter; printed starting hands remain authoritative.'); const seat = (faction: string) => { const p = game.players.find(p => p.faction === faction); assert.ok(p); return p.id; }; const richese = seat('richese'), choam = seat('choam'), guild = seat('guild'), observer = seat('emperor'); const actions: DiscoveryNativeE2SkillsStep[] = [], staging: string[] = []; if (game.status === 'lobby') { if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); game.discoveryEnabled = true; for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' }); } assert.equal(game.discoveryEnabled, true); assert.ok(!game.strongholdCards || game.advanced); const initial = structuredClone(game); if (game.status === 'lobby') { const passive: LeaderSkillId[] = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic']; const selected = game.players.map(p => requestedSkill(p.faction) ?? passive.shift()!); game = options.useActualOffers ? initializeLeaderSkillsGameForAudit(game) : withOffers(selected, () => initializeLeaderSkillsGameForAudit(game)); if (!options.useActualOffers) staging.push('Controlled only the original all14 offer shuffle; native subsequent deals and offers are authoritative.'); } const setup = structuredClone(game); game = completeDiscoveryNativeE2SkillsSetup(game, options.useActualOffers, actions); const afterSetup = structuredClone(game); const face: DiscoveryTokenFace = kind === 'stash' ? 'treachery-card-stash' : 'shrine'; const printed = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north'; const placement = DISCOVERY_CARD_PLACEMENTS[printed]; 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]); } staging.push('Reordered four original unplayed Spice Cards and selected the original physical Discovery placement lottery.'); game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 1 && discoveryNativeE2SkillsClean(g), actions); const afterFirstStorm = structuredClone(game); game = withPlacement(game, face, () => advanceDiscoveryNativeE2Skills(game, g => g.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions)); const collector = kind === 'stash' ? choam : guild; game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 5 && g.active === collector && discoveryNativeE2SkillsClean(g), actions); assert.ok(discoveryNativeE2SkillsPlayer(game, richese).hand.some(card => card.id === greenId(game)), 'Acquire the original physical green card through Richese declaration, auction and unbid decision.'); game = stepDiscoveryNativeE2Skills(game, { actor: collector, action: { type: 'ship', territory: placement.territory, sector: placement.sector, amount: 1, elite: 0, allyPayment: 0, } }, actions); game = settleDiscoveryNativeE2Skills(game, actions); game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 7 && discoveryNativeE2SkillsClean(g), actions); const token = game.discoveries!.tokens.find(t => t.face === face)!; assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id)); const trainers = { richese: game.leaderSkills!.assignments.find(a => a.owner === richese)!.leader, choam: game.leaderSkills!.assignments.find(a => a.owner === choam)!.leader }; return { initial, setup, afterSetup, afterFirstStorm, game, actions, staging, kind, richese, choam, guild, observer, collector, token: token.id, face, parent: placement.territory, parentSector: placement.sector, trainers, green: greenId(game) }; } export function revealDiscoveryNativeE2Skills(fixture: DiscoveryNativeE2SkillsFixture): Game { const game = stepDiscoveryNativeE2Skills(fixture.game, { actor: fixture.collector, action: { type: 'discovery', token: fixture.token, reveal: false } }, fixture.actions); return stepDiscoveryNativeE2Skills(game, { actor: fixture.collector, action: { type: 'discovery', token: fixture.token, reveal: true } }, fixture.actions); } /** Exact next human path: inspect/reveal → original end-Mentat claims → * next-turn free parent-to-Shrine entry → Storm/Bidding → requested Movement. * Entitlements are never prefilled. */ export function discoveryNativeE2SkillsMovementWindow(fixture: DiscoveryNativeE2SkillsFixture, actor = fixture.richese): DiscoveryNativeE2SkillsCheckpoint { assert.notEqual(fixture.kind, 'stash'); let game = revealDiscoveryNativeE2Skills(fixture); game = advanceDiscoveryNativeE2Skills(game, g => g.turn === 1 && g.phase === 8 && discoveryNativeE2SkillsClean(g), fixture.actions); let beforeFirstMentat = structuredClone(game), firstMentatStep = nextDiscoveryNativeE2SkillsStep(game); while (game.turn === 1) { beforeFirstMentat = structuredClone(game); firstMentatStep = nextDiscoveryNativeE2SkillsStep(game); game = stepDiscoveryNativeE2Skills(game, firstMentatStep, fixture.actions); } const afterFirstMentat = structuredClone(game); game = advanceDiscoveryNativeE2Skills(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === fixture.collector, fixture.actions); const offer = viewGame(game, fixture.collector).discoveryEntry!; const entry = discoveryEntryMoveAction(viewGame(game, fixture.collector), offer.sources); assert.ok(entry); game = stepDiscoveryNativeE2Skills(game, { actor: fixture.collector, action: entry }, fixture.actions); game = settleDiscoveryNativeE2Skills(game, fixture.actions); assert.equal(discoveryNativeE2SkillsPlayer(game, fixture.collector).forces[`${fixture.face}:0`], 1); game = advanceDiscoveryNativeE2Skills(game, g => g.turn === 2 && g.phase === 5 && g.active === actor && discoveryNativeE2SkillsClean(g), fixture.actions); return { beforeFirstMentat, firstMentatStep, afterFirstMentat, game }; } export function discoveryNativeE2SkillsInvoiceAction(game: Game): { action: Action; key: string } { const target = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm) && game.players.every(p => Object.entries(p.forces).every(([key, n]) => !n || !key.startsWith(`${t.id}:`)))); assert.ok(target); return { action: { type: 'ship', territory: target.id, sector: target.sectors[0], amount: 6, elite: 0, allyPayment: 0, smuggler: true }, key: `${target.id}:${target.sectors[0]}` }; } /** Marker-only: no companion, no ordinary Richese forces at the battle site. * Optional reserve cap conserves counters in Polar Sink. The original green * cache card is committed ONLY as Planetologist weapon, never its native power. */ export function prepareDiscoveryNativeE2SkillsMarkerBattle(fixture: DiscoveryNativeE2SkillsFixture, options: Pick = {}): DiscoveryNativeE2MarkerBattle { assert.ok(fixture.game.leaderSkills!.assignments.some(a => a.owner === fixture.richese && a.skill === 'planetologist'), 'The specific green consumer requires a genuinely offered and selected Planetologist; the human marker window does not manufacture it.'); const window = discoveryNativeE2SkillsMovementWindow(fixture); let game = window.game; const player = discoveryNativeE2SkillsPlayer(game, fixture.richese), value = options.marker ?? 5; if (options.reserveCap !== undefined) { assert.ok(options.reserveCap >= 0 && options.reserveCap <= player.reserves); const outside = player.reserves - options.reserveCap; player.forces['polar_sink:0'] = (player.forces['polar_sink:0'] ?? 0) + outside; player.reserves -= outside; fixture.staging.push(`Conserved ${outside} original Richese reserve counters into Polar Sink outside the nested battle.`); } const physical = player.noField!.tokens.find(token => token.value === value)!; const defense = holdDiscoveryNativeE2SkillsCard(game, fixture.richese, card => card.kind === 'shield', fixture.staging); const beforeShipment = structuredClone(game); const shipmentStep: DiscoveryNativeE2SkillsStep = { actor: fixture.richese, action: { type: 'ship', territory: fixture.face, sector: 0, noField: physical.id, event: player.noFieldEvent, allyPayment: 0, } }; game = stepDiscoveryNativeE2Skills(game, shipmentStep, fixture.actions); game = settleDiscoveryNativeE2Skills(game, fixture.actions); const afterShipment = structuredClone(game); const enemy = discoveryNativeE2SkillsPlayer(game, fixture.guild); assert.ok(enemy.reserves >= 7); enemy.reserves -= 7; enemy.forces[`${fixture.face}:0`] += 7; fixture.staging.push('Conserved seven original Guild reserve counters into its actually entered Shrine, providing eight physical opposing battle counters.'); game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 6 && discoveryNativeE2SkillsClean(g), fixture.actions); game = openSkillsTechBattle(game, fixture.richese, fixture.guild, fixture.face, true); const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader)); const trainer = discoveryNativeE2SkillsPlayer(game, fixture.richese).leaders.find(l => l.id === fixture.trainers.richese)!; const opponent = discoveryNativeE2SkillsPlayer(game, fixture.guild).leaders.filter(l => !l.dead && !l.usedAt && !assigned.has(l.id) && l.strength <= trainer.strength + 1 && l.strength >= trainer.strength - 3).sort((a, b) => b.strength - a.strength)[0]; assert.ok(opponent); const opposingDial = trainer.strength + 1 - opponent.strength; assert.ok(opposingDial * (game.advanced ? 2 : 1) <= 8); const materialized = Math.min(value, discoveryNativeE2SkillsPlayer(game, fixture.richese).reserves); return { beforeShipment, shipmentStep, afterShipment, game, token: physical.id, value, materialized, key: `${fixture.face}:0`, ownerPlan: { type: 'battlePlan', leader: fixture.trainers.richese, dial: 0, support: 0, weapon: fixture.green, defense: defense.id }, opponentPlan: { type: 'battlePlan', leader: opponent.id, dial: opposingDial, support: 0 } }; } export function finishDiscoveryNativeE2SkillsBattle(game: Game, stop: 'cleanup' | 'complete' = 'complete'): Game { return finishSkillsTechBattle(game, stop); }