import assert from 'node:assert/strict'; import { applyAction, createGame, initializeFactionExpansionsGameForAudit, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, type Game, } from '../game/engine'; import { MOBILE_LOCATION, territory } from '../game/board'; import type { Card } from '../game/cards'; import type { FactionId } from '../game/catalog'; import { nativeFactionTechProfile } from '../game/faction-module-profile'; import { createTechTokens } from '../game/tech-tokens'; import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; export type NativeTechFamily = 'ix-choam' | 'richese' | 'tleilaxu'; export type NativeTechFactionsOptions = { initial?: Game; family?: NativeTechFamily; strongholds?: boolean; advanced?: boolean; ownerFaction?: 'ixians' | 'choam'; stone?: boolean; }; export type NativeTechFactionsFixture = { initial: Game; afterSetup: Game; beforeFirstMentat?: Game; firstMentatStep?: StrongholdFactionsNativeStep; afterFirstMentat?: Game; beforeShipment?: Game; shipmentStep?: StrongholdFactionsNativeStep; afterShipment?: Game; acquisition?: { before: Game; after: Game; steps: StrongholdFactionsNativeStep[] }; game: Game; beforeBattle: Game; battleStep: StrongholdFactionsNativeStep; planActions: StrongholdFactionsNativeStep[]; owner: string; opponent: string; family: NativeTechFamily; kind: 'arrakeen' | 'sietch_tabr' | 'habbanya_ridge_sietch'; location: string; actions: StrongholdFactionsNativeStep[]; staging: string[]; }; export const nativeTechFactionsPlayer = (game: Game, actor: string) => { const player = game.players.find(p => p.id === actor); assert.ok(player, `Missing native Tech seat ${actor}`); return player; }; const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; export function nextNativeTechFactionsStep(game: Game): StrongholdFactionsNativeStep | null { if (!game.phaseOpening && !game.response && game.decision?.kind === 'techToken') return { actor: game.decision.player, action: { type: 'decision', token: game.decision.choices[0] } }; return nextRicheseStrongholdsNativeStep(game); } function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game { actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action); } function advance(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[]): Game { for (let n = 0; n < 1800; n++) { if (until(game)) return game; assert.notEqual(game.status, 'finished'); const step = nextNativeTechFactionsStep(game); assert.ok(step, 'The original battle requires an unsealed human plan'); // Real first-turn dials leave every printed fixture site outside the next Storm band. if (game.turn === 1 && step.action.type === 'stormDial') step.action.amount = 0; game = run(game, step, actions); } throw Error('Native Tech continuation did not reach its requested boundary'); } function withSetupEntropy(action: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); crypto.getRandomValues = (array: V): V => { assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array; }; try { return action(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } /** Original fresh native setup; no Leader Skills, public-start bypass or redeal. * Default initializer entropy is deterministic; supplied undealt setup is continued * as saved. Actual wallets and Tech assignments are never staged or repaired. */ export function initializeNativeTechFactionsSetup(options: NativeTechFactionsOptions = {}): Game { const family = options.family ?? 'ix-choam'; const factions: FactionId[] = family === 'ix-choam' ? ['ixians', 'choam', 'guild'] : family === 'richese' ? ['richese', 'choam', 'guild'] : ['ixians', 'tleilaxu', 'guild']; const decks = family === 'ix-choam' ? ['ix', 'choam'] : family === 'richese' ? ['choam'] : ['ix']; const strongholds = options.strongholds ?? true; let game = options.initial ? structuredClone(options.initial) : createGame('NATIVETECHFACTIONS', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? true, decks); if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); assert.deepEqual(game.players.map(p => p.faction).sort(), [...factions].sort()); assert.deepEqual([...game.expansions].sort(), [...decks].sort()); assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => !p.hand.length), 'Use the original fresh undealt native lobby or setup'); if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); if (strongholds) assert.equal(game.advanced, true); if (game.status === 'setup') { assert.ok(nativeFactionTechProfile(game), 'Continue only the bounded original native Tech setup without unrelated overlays'); assert.equal(!!game.strongholdCards, strongholds); return game; } if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); assert.deepEqual(game.techTokens, createTechTokens()); for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); const initialize = () => strongholds ? initializeStrongholdFactionsGameForAudit(game) : initializeFactionExpansionsGameForAudit(game); return options.initial ? initialize() : withSetupEntropy(initialize); } function reserveBoard(game: Game) { for (const player of game.players) { player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); player.forces = {}; if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); player.elites.forces = {}; } } } function place(game: Game, actor: string, key: string, count: number, elite = 0) { const player = nativeTechFactionsPlayer(game, actor); assert.ok(player.reserves >= count && (player.elites?.reserves ?? 0) >= elite && count >= elite); player.reserves -= count; player.forces[key] = (player.forces[key] ?? 0) + count; if (elite) { player.elites!.reserves -= elite; player.elites!.forces[key] = (player.elites!.forces[key] ?? 0) + elite; } } function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]) { const player = nativeTechFactionsPlayer(game, actor), held = player.hand.find(c => c.kind === kind); if (held) return held.id; const index = game.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0); assert.ok(player.hand.length < (player.faction === 'choam' ? 5 : 4)); const card = game.deck.splice(index, 1)[0]; player.hand.push(card); staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`); return card.id; } /** Controlled positions conserve native physical counters/cards after genuine * setup. Real ship, cache acquisition, final Mentat, plans, payments and aftermath * remain applyAction decisions. No wallet, owner, phase or earned receipt assignment. */ export function createNativeTechFactionsFixture(options: NativeTechFactionsOptions = {}): NativeTechFactionsFixture { const family = options.family ?? 'ix-choam', strongholds = options.strongholds ?? true; const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = []; let game = initializeNativeTechFactionsSetup(options); const initial = structuredClone(game); for (let n = 0; game.status === 'setup' && n < 200; n++) { const step = nextNativeTechFactionsStep(game); assert.ok(step); // Preserve the native Ix remainder shuffle. Seed only the actual first // Face Dancer draw, as in the existing Tleilaxu Stronghold fixture. if (family === 'tleilaxu' && game.decision?.kind === 'ixSetup') { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const original = crypto.getRandomValues.bind(crypto); let remainderCalls = Math.max(0, game.ixSetupCards!.length - 2); let faceCalls = game.players.reduce((sum, p) => sum + p.leaders.length, 0) + 1 - new Set(game.players.flatMap(p => p.traitors)).size - 1; crypto.getRandomValues = (array: V): V => { assert.ok(array); if (remainderCalls > 0) { remainderCalls--; Reflect.apply(original, crypto, [array]); return array; } if (faceCalls-- > 0) { new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array; } Reflect.apply(original, crypto, [array]); return array; }; try { game = run(game, step, actions); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } staging.push('Scoped first-draw entropy: only original native Face Dancer Traitor Deck shuffle; preserve Ix remainder and restore immediately.'); } else game = run(game, step, actions); } assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game); const owner = game.players.find(p => p.faction === (family === 'richese' ? 'richese' : family === 'tleilaxu' ? 'ixians' : options.ownerFaction ?? 'ixians'))!.id; const opponent = game.players.find(p => p.faction === 'guild')!.id; const kind = family === 'richese' ? 'habbanya_ridge_sietch' : family === 'tleilaxu' ? 'sietch_tabr' : 'arrakeen'; const location = `${kind}:${territory(kind).sectors[0]}`; let acquisition: NativeTechFactionsFixture['acquisition']; if (family === 'richese') { game = advance(game, state => state.decision?.kind === 'richeseCache', actions); const before = structuredClone(game), steps: StrongholdFactionsNativeStep[] = []; while (!nativeTechFactionsPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner')) { const step = nextNativeTechFactionsStep(game); assert.ok(step); steps.push(structuredClone(step)); game = run(game, step, actions); assert.ok(steps.length < 100); } acquisition = { before, steps, after: structuredClone(game) }; } game = advance(game, state => state.phase === 5 && clean(state) && (family !== 'richese' || state.active === owner), actions); reserveBoard(game); staging.push('After real setup: return existing board counters through their own subtype reserves; keep actual native bank balances and assigned Tech custody.'); let beforeShipment: Game | undefined, shipmentStep: StrongholdFactionsNativeStep | undefined, afterShipment: Game | undefined; if (family === 'richese') { const token = nativeTechFactionsPlayer(game, owner).noField!.tokens.find(t => t.value === 5)!; beforeShipment = structuredClone(game); shipmentStep = { actor: owner, action: { type: 'ship', noField: token.id, event: nativeTechFactionsPlayer(game, owner).noFieldEvent, territory: kind, sector: territory(kind).sectors[0] } }; game = run(game, shipmentStep, actions); game = advance(game, clean, actions); afterShipment = structuredClone(game); } let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined, afterFirstMentat: Game | undefined; if (strongholds) { game = advance(game, state => state.phase === 8 && clean(state), actions); reserveBoard(game); if (family !== 'richese') place(game, owner, location, 1); const ix = game.players.find(p => p.faction === 'ixians'); if (ix) place(game, ix.id, MOBILE_LOCATION, 6, 3); staging.push('Before actual first END Mentat: native owner controls its printed battle site (Richese by real marker); Ix retains six original HMS counters including three cyborgs. Nobody controls three Strongholds.'); while (game.turn === 1) { const step = nextNativeTechFactionsStep(game); assert.ok(step); const before = structuredClone(game); game = run(game, step, actions); if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; } } afterFirstMentat = structuredClone(game); assert.equal(game.strongholdCards!.owners[kind], owner); game = advance(game, state => state.phase === 5 && clean(state), actions); reserveBoard(game); } if (family === 'richese' && options.stone) { const token = nativeTechFactionsPlayer(game, owner).noField!.deployed!.tokenId; game = run(game, { actor: owner, action: { type: 'revealNoField', event: nativeTechFactionsPlayer(game, owner).noFieldEvent, token } }, actions); reserveBoard(game); } if (family !== 'richese' || options.stone) place(game, owner, location, 6, nativeTechFactionsPlayer(game, owner).faction === 'ixians' ? 3 : 0); place(game, opponent, location, 6); if (family === 'tleilaxu') { const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')!.id; place(game, tleilaxu, `arrakeen:${territory('arrakeen').sectors[0]}`, 3); } staging.push('Before genuine movement completion: conserved six opposing counters, six winner counters (Ix three suboids/three cyborgs) or actual Richese marker; Tleilaxu has three physical Arrakeen replacement sources. No money or ownership staging.'); const player = nativeTechFactionsPlayer(game, owner); const leader = family === 'tleilaxu' ? player.leaders.filter(l => !l.dead && game.players.find(p => p.faction === 'tleilaxu')!.faceDancers!.some(c => c.leader === l.id && !c.revealed)).sort((a, b) => b.strength - a.strength)[0] : player.leaders.filter(l => l.strength > 0 && !l.dead).sort((a, b) => b.strength - a.strength)[0]; assert.ok(leader, 'The original native draw must supply the winner leader'); const enemyLeader = family === 'tleilaxu' || options.ownerFaction === 'choam' ? nativeTechFactionsPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0] : nativeTechFactionsPlayer(game, opponent).leaders.find(l => l.strength === 5 && !l.dead); assert.ok(enemyLeader); const weapon = family === 'tleilaxu' ? heldCard(game, owner, 'projectile', staging) : options.stone ? 'richese-stone-burner' : null; const defense = family === 'tleilaxu' ? heldCard(game, owner, 'shield', staging) : null; game = advance(game, state => state.phase === 6 && clean(state), actions); const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === owner || actor === opponent); const battleStep: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner } }; game = run(game, battleStep, actions); game = advance(game, state => !!state.battle && !state.battle.revealed && !nextNativeTechFactionsStep(state), actions); const ix = player.faction === 'ixians'; const dial = family === 'tleilaxu' ? 2 : family === 'richese' ? options.stone ? 2 : 1 : ix ? game.advanced ? 6 : 4 : 3; const support = game.advanced ? family === 'tleilaxu' ? 1 : family === 'richese' ? dial : 3 : 0; const enemyDial = options.ownerFaction === 'choam' ? 2 : family === 'richese' ? options.stone ? 2 : 0 : 1.5; const planActions: StrongholdFactionsNativeStep[] = [ { actor: owner, action: { type: 'battlePlan', dial, support, leader: leader.id, weapon, defense } }, { actor: opponent, action: { type: 'battlePlan', dial: game.advanced ? enemyDial : 1, support: game.advanced ? enemyDial === 2 ? 2 : enemyDial ? 1 : 0 : 0, leader: enemyLeader.id, weapon: null, defense: null } }, ]; return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeShipment, shipmentStep, afterShipment, acquisition, game, beforeBattle, battleStep, planActions, owner, opponent, family, kind, location, actions, staging }; } export function revealNativeTechFactionsBattle(fixture: Pick): Game { let game = structuredClone(fixture.game); for (const step of fixture.planActions) { game = applyAction(game, step.actor, step.action); while (game.response || game.decision?.kind === 'fullPlanOffer') { const next = nextNativeTechFactionsStep(game); assert.ok(next); game = applyAction(game, next.actor, next.action); } } assert.ok(game.battle?.revealed); return game; } export function advanceNativeTechFactions(state: Game, until: (game: Game) => boolean): Game { return advance(structuredClone(state), until, []); } export function finishNativeTechFactionsBattle(state: Game): Game { return advanceNativeTechFactions(state, game => !game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard); } export function finishNativeTechFactionsTurn(state: Game): Game { return advanceNativeTechFactions(state, game => game.turn > state.turn); }