/** * Offline, deterministic Advanced base-faction calibration through the shared setup initializer. Never import into the app/server: * this harness replaces crypto.getRandomValues only within its own Node process. * Run: node --import tsx tools/ai-calibration-advanced.ts --seed 20260909 --out /tmp/dune-ai-calibration-advanced.json */ import { createHash } from 'node:crypto'; import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { applyAction, initializeBaseGameForAudit, createGame, joinGame, newPlayer, RuleError, viewGame, type Game, } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; import { FACTIONS, type FactionId } from '../game/catalog'; import { baseDeck } from '../game/cards'; const args = process.argv.slice(2); function argument(name: string, fallback: string): string { const index = args.indexOf(name); if (index < 0) return fallback; if (!args[index + 1] || args[index + 1].startsWith('--')) throw new Error(`Missing value for ${name}`); return args[index + 1]; } function positive(name: string, fallback: number): number { const value = Number(argument(name, String(fallback))); if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`); return value; } for (let i = 0; i < args.length; i += 2) if (!['--seed', '--games', '--max-actions', '--out'].includes(args[i])) throw new Error(`Unknown argument ${args[i]}`); const masterSeed = positive('--seed', 20260923) >>> 0; const gameLimit = positive('--games', 456); const maxActions = positive('--max-actions', 6000); if (gameLimit > 456) throw new Error('This design contains 456 games.'); const output = resolve( argument('--out', '/tmp/dune-ai-calibration-advanced.json'), ); const started = Date.now(); function sourceFingerprint() { const files = readdirSync('game') .filter((name) => name.endsWith('.ts')) .sort() .map((name) => `game/${name}`); files.push('tools/ai-calibration-advanced.ts'); const hashes = Object.fromEntries( files.map((file) => [ file, createHash('sha256').update(readFileSync(file)).digest('hex'), ]), ); return { combined: createHash('sha256').update(JSON.stringify(hashes)).digest('hex'), files: hashes, }; } const sources = sourceFingerprint(); // Mulberry32 produces deterministic 32-bit words for the engine's shuffle API. function generator(seed: number) { let state = seed >>> 0; return () => { state = (state + 0x6d2b79f5) >>> 0; let word = Math.imul(state ^ (state >>> 15), state | 1); word ^= word + Math.imul(word ^ (word >>> 7), word | 61); return (word ^ (word >>> 14)) >>> 0; }; } let randomWord = generator(masterSeed); let uuidWord = generator(masterSeed ^ 0xa5a5a5a5); const cryptoObject = globalThis.crypto; const originalUuid = Object.getOwnPropertyDescriptor( cryptoObject, 'randomUUID', ); Object.defineProperty(cryptoObject, 'randomUUID', { configurable: true, value: () => { const hex = Array.from({ length: 4 }, () => uuidWord().toString(16).padStart(8, '0'), ) .join('') .split(''); hex[12] = '4'; hex[16] = ((parseInt(hex[16], 16) & 3) | 8).toString(16); const id = hex.join(''); return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`; }, }); const originalRandom = Object.getOwnPropertyDescriptor( cryptoObject, 'getRandomValues', ); Object.defineProperty(cryptoObject, 'getRandomValues', { configurable: true, value: (array: T): T => { if (!(array instanceof Uint32Array)) throw new Error( 'Calibration only supports the engine Uint32Array RNG contract.', ); for (let i = 0; i < array.length; i++) array[i] = randomWord(); return array; }, }); type Seat = { id: string; seat: number; circle: number; faction: FactionId; level: Difficulty; }; type Outcome = | 'complete' | 'deadlock' | 'action-limit' | 'exception' | 'invariant'; type Row = { index: number; seed: number; block: number; shift: number; replicate: number; direction: 1 | -1; rosterMask: number; setupActions: number; setupTransitions: string[]; jsonRoundTrips: number; rejectionSamples: { player: string; level: Difficulty; rank: number; action: object; reason: string; context: object; }[]; players: number; code: string; seats: Seat[]; outcome: Outcome; turn: number; phase: number; accepted: number; rejected: number; rejectionReasons: Record; rejectedByLevel: Record; winner: string[]; elapsedMs: number; acceptedByLevel: Record; firstCandidatesByLevel: Record; firstRejectedByLevel: Record; acceptedCandidateRanks: Record; acceptedTraceHash?: string; finalStateHash?: string; failure?: string; context?: object; }; const rows: Row[] = []; const baseFactions = FACTIONS.filter((f) => f.expansion === 'base').map( (f) => f.id, ); const expectedCards = baseDeck() .map((c) => c.id) .sort(); function invariant(g: Game) { if (!g.advanced || g.expansions.length || g.techTokens) throw new Error('Configuration changed outside Advanced base scope'); const undealtSetup = g.setupStage === 'prediction' || g.setupStage === 'traitors'; if (g.setupStage && g.players.some((p) => p.hand.length > 0)) throw new Error('Treachery dealt before force setup completes'); if ( undealtSetup && g.players.some( (p) => p.spice !== 0 || Object.keys(p.forces).length || p.elites, ) ) throw new Error( 'Starting spice or forces exposed before traitor choices finish', ); if ( g.setupStage === 'prediction' && g.players.some((p) => p.traitors.length || p.traitorChoices.length) ) throw new Error('Private traitors dealt before prediction'); const heldCards = [ ...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand), ...(g.auction?.cards.slice( g.auction.index + (g.currentAuctionSale?.origin === 'normal' ? 1 : 0), ) ?? []), ]; if ( new Set(heldCards.map((c) => c.id)).size !== heldCards.length || JSON.stringify(heldCards.map((c) => c.id).sort()) !== JSON.stringify(expectedCards) ) throw new Error('Physical base treachery inventory changed'); if (g.setupStage !== 'prediction') { const traitors = [ ...(g.traitorReserve ?? []), ...g.players.flatMap((p) => [...p.traitors, ...p.traitorChoices]), ]; if ( new Set(traitors).size !== traitors.length || JSON.stringify(traitors.slice().sort()) !== JSON.stringify( g.players.flatMap((p) => p.leaders.map((l) => l.id)).sort(), ) ) throw new Error('Participating traitor inventory changed'); } for (const p of g.players) { const onBoard = Object.values(p.forces).reduce((a, b) => a + b, 0); if (p.reserves + p.tanks + onBoard !== 20) throw new Error(`Force conservation: ${p.id}/${p.faction}`); if ( ![p.reserves, p.tanks, ...Object.values(p.forces), p.spice].every( (n) => Number.isSafeInteger(n) && n >= 0, ) ) throw new Error(`Nonnegative integer resources: ${p.id}/${p.faction}`); if (p.faction === 'emperor' || p.faction === 'fremen') { const e = p.elites; if (!undealtSetup && !e) throw new Error(`Missing Advanced elites: ${p.id}`); if (e) { const total = e.reserves + e.tanks + Object.values(e.forces).reduce((a, b) => a + b, 0); if ( total !== (p.faction === 'emperor' ? 5 : 3) || e.reserves > p.reserves || e.tanks > p.tanks || e.revived > 1 || ![e.reserves, e.tanks, e.revived, ...Object.values(e.forces)].every( (n) => Number.isSafeInteger(n) && n >= 0, ) || Object.entries(e.forces).some( ([key, amount]) => amount > (p.forces[key] ?? 0), ) ) throw new Error(`Elite conservation/quota: ${p.id}`); } } else if (p.elites) throw new Error(`Unexpected base elite force type: ${p.id}`); if ( p.leaders.length !== 5 || new Set(p.leaders.map((l) => l.id)).size !== 5 || p.leaders.some((l) => !Number.isSafeInteger(l.deaths) || l.deaths < 0) ) throw new Error(`Native leader inventory: ${p.id}`); const temporary = g.decision?.kind === 'handExchange' && g.decision.player === p.id ? g.decision.count : 0; if (p.hand.length > (p.faction === 'harkonnen' ? 8 : 4) + temporary) throw new Error(`Hand limit: ${p.id}/${p.faction}`); } } function context(g: Game) { return { status: g.status, setupStage: g.setupStage ?? null, turn: g.turn, phase: g.phase, active: g.active, decision: g.decision?.kind ?? null, decisionPlayer: g.decision?.player ?? null, response: g.response?.kind ?? null, responseOwner: g.response?.owner ?? null, ready: g.ready, battle: g.battle ? { territory: g.battle.territory, revealed: g.battle.revealed, plans: Object.keys(g.battle.plans), } : null, }; } function play( index: number, block: number, shift: number, replicate: number, direction: 1 | -1, rosterMask: number, ): Row { const roster = baseFactions.filter((_, i) => rosterMask & (1 << i)); const count = roster.length; // The four shifts in a roster block share deal seed/code and stable seat IDs. const seed = (masterSeed + Math.imul(count, 0x9e3779b9) + Math.imul(block, 0x85ebca6b) + Math.imul(replicate, 0xc2b2ae35) + (direction === -1 ? 0x27d4eb2f : 0)) >>> 0; randomWord = generator(seed); uuidWord = generator(seed ^ 0xa5a5a5a5); const code = `S${seed.toString(36).padStart(7, '0')}`; const seats: Seat[] = Array.from({ length: count }, (_, seat) => ({ id: `seat-${seat}`, seat, circle: ((block + seat) % 6) + 1, faction: roster[direction === 1 ? seat : count - 1 - seat], level: DIFFICULTIES[(seat + shift) % DIFFICULTIES.length], })); const row: Row = { index, seed, block, shift, replicate, direction, rosterMask, setupActions: 0, setupTransitions: [], jsonRoundTrips: 0, rejectionSamples: [], players: count, code, seats, outcome: 'action-limit', turn: 1, phase: 0, accepted: 0, rejected: 0, rejectionReasons: {}, rejectedByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 }, winner: [], elapsedMs: 0, acceptedByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 }, firstCandidatesByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 }, firstRejectedByLevel: { Easy: 0, Medium: 0, Hard: 0, Brutal: 0 }, acceptedCandidateRanks: {}, }; const gameStart = Date.now(); const trace = createHash('sha256'); let g = createGame( code, newPlayer(seats[0].id, seats[0].faction, seats[0].faction), true, ); try { // Assign each genuine lobby seat before the next joins; this also works with all six circles occupied. for (const [i, seat] of seats.entries()) { if (i) joinGame(g, newPlayer(seat.id, seat.faction, seat.faction)); if (g.playerPositions![seat.id] !== seat.circle) g = applyAction(g, seat.id, { type: 'seatPosition', position: seat.circle, }); } for (const seat of seats) g = applyAction(g, seat.id, { type: 'ready' }); for (const [i, p] of g.players.entries()) p.bot = seats[i].level; // Offline-only initializer calls the same staged production setup; the public Advanced start gate stays closed. const lobbyHash = JSON.stringify(g); const initialized = initializeBaseGameForAudit(g); if (JSON.stringify(g) !== lobbyHash) throw new Error('Audit initializer mutated the source lobby'); g = initialized; invariant(g); row.setupTransitions.push(g.setupStage ?? g.status); for (let step = 0; step < maxActions; step++) { let next: Game | undefined; // Match runBots' player/candidate order while counting its caught RuleErrors. for (const p of g.players.filter((p) => p.bot)) { const view = viewGame(g, p.id); for (const other of view.players.filter((s) => s.id !== p.id)) if ( other.hand !== undefined || other.traitors !== undefined || other.traitorChoices !== undefined || other.prediction !== undefined ) throw new Error( 'Private projection exposed another seat’s hidden setup/hand information', ); if ('deck' in view || 'spiceDeck' in view || 'traitorReserve' in view) throw new Error('Private deck/reserve projected'); const viewSnapshot = JSON.stringify(view); const actions = botActions(view); if (JSON.stringify(view) !== viewSnapshot) throw new Error('Candidate generation mutated the player projection'); for (const [rank, action] of actions.entries()) { if (rank === 0) row.firstCandidatesByLevel[p.bot!]++; try { next = applyAction(g, p.id, action); if (g.status === 'setup') row.setupActions++; row.acceptedByLevel[p.bot!]++; const rankKey = String(rank + 1); row.acceptedCandidateRanks[rankKey] = (row.acceptedCandidateRanks[rankKey] ?? 0) + 1; trace.update(JSON.stringify({ player: p.id, action }) + '\n'); break; } catch (error) { if (!(error instanceof RuleError)) throw error; const mode = typeof action.mode === 'string' ? `/${action.mode}` : ''; const reason = `${action.type}${mode}: ${error.message}`; row.rejected++; if (row.rejectionSamples.length < 8) row.rejectionSamples.push({ player: p.id, level: p.bot!, rank: rank + 1, action, reason, context: context(g), }); if (row.rejected === 1) { const diagnostic = `${output}.first-rejection-${index}.json`; writeFileSync( diagnostic, JSON.stringify( { index, seed, rosterMask, seats, state: g, player: p.id, action, reason, }, null, 2, ), ); console.log( JSON.stringify({ problem: 'rejected-candidate', index, diagnostic, player: p.id, level: p.bot, reason, context: context(g), }), ); } if (rank === 0) row.firstRejectedByLevel[p.bot!]++; row.rejectedByLevel[p.bot!]++; row.rejectionReasons[reason] = (row.rejectionReasons[reason] ?? 0) + 1; } } if (next) break; } if (!next) { row.outcome = 'deadlock'; row.context = context(g); break; } const previousStage = g.setupStage ?? g.status; g = next; row.accepted++; const currentStage = g.setupStage ?? g.status; if ( previousStage !== currentStage && (previousStage === 'prediction' || previousStage === 'traitors' || previousStage === 'forces') ) row.setupTransitions.push(currentStage); // Subsequent real actions continue from serialized state, not merely a comparison-only clone. if (previousStage !== currentStage || row.accepted % 25 === 0) { const beforeViews = g.players.map((p) => JSON.stringify(viewGame(g, p.id)), ); const restored = JSON.parse(JSON.stringify(g)) as Game; if ( JSON.stringify(g) !== JSON.stringify(restored) || JSON.stringify(beforeViews) !== JSON.stringify( restored.players.map((p) => JSON.stringify(viewGame(restored, p.id)), ), ) ) throw new Error( 'JSON continuation changed state or private projections', ); g = restored; row.jsonRoundTrips++; } try { invariant(g); } catch (error) { row.outcome = 'invariant'; row.failure = String(error); row.context = context(g); break; } if (g.status === 'finished') { row.outcome = 'complete'; row.winner = [...g.winner]; break; } } } catch (error) { row.outcome = 'exception'; row.failure = String(error); row.context = context(g); } if (row.outcome === 'action-limit') row.context = context(g); if (row.outcome !== 'complete') writeFileSync( `${output}.failure-${index}.json`, JSON.stringify({ row, state: g }, null, 2), ); row.turn = g.turn; row.phase = g.phase; row.elapsedMs = Date.now() - gameStart; row.acceptedTraceHash = trace.digest('hex'); row.finalStateHash = createHash('sha256') .update(JSON.stringify(g)) .digest('hex'); return row; } function statistics(subset: Row[]) { const completed = subset.filter((r) => r.outcome === 'complete'); return { games: subset.length, complete: completed.length, outcomes: Object.fromEntries( ['complete', 'deadlock', 'action-limit', 'exception', 'invariant'].map( (status) => [status, subset.filter((r) => r.outcome === status).length], ), ), setupActions: subset.reduce((sum, r) => sum + r.setupActions, 0), jsonRoundTrips: subset.reduce((sum, r) => sum + r.jsonRoundTrips, 0), acceptedActions: subset.reduce((sum, r) => sum + r.accepted, 0), rejectedCandidates: subset.reduce((sum, r) => sum + r.rejected, 0), gamesWithRejections: subset.filter((r) => r.rejected > 0).length, meanCompletedTurns: completed.length ? completed.reduce((sum, r) => sum + r.turn, 0) / completed.length : null, turnHistogram: Object.fromEntries( Array.from({ length: 10 }, (_, i) => [ i + 1, completed.filter((r) => r.turn === i + 1).length, ]), ), byLevel: Object.fromEntries( DIFFICULTIES.map((level) => { const appearances = subset.flatMap((r) => r.seats.filter((s) => s.level === level).map((s) => ({ r, s })), ); const completeSeats = appearances.filter( ({ r }) => r.outcome === 'complete', ); const winning = completeSeats.filter(({ r, s }) => r.winner.includes(s.id), ); return [ level, { appearances: appearances.length, completedAppearances: completeSeats.length, winsIncludingShared: winning.length, winRateIncludingShared: completeSeats.length ? winning.length / completeSeats.length : null, fractionalWinCredit: winning.reduce( (sum, { r }) => sum + 1 / r.winner.length, 0, ), acceptedActions: subset.reduce( (sum, r) => sum + r.acceptedByLevel[level], 0, ), firstCandidates: subset.reduce( (sum, r) => sum + r.firstCandidatesByLevel[level], 0, ), firstRejected: subset.reduce( (sum, r) => sum + r.firstRejectedByLevel[level], 0, ), rejectedCandidates: subset.reduce( (sum, r) => sum + r.rejectedByLevel[level], 0, ), }, ]; }), ), }; } try { // Interleave counts so a 20-game pilot covers one roster of each size at every shift. const buckets = [2, 3, 4, 5, 6].map((count) => Array.from({ length: 64 }, (_, mask) => mask).filter( (mask) => baseFactions.filter((_, i) => mask & (1 << i)).length === count, ), ); const masks: number[] = []; for (let i = 0; buckets.some((b) => i < b.length); i++) for (const bucket of buckets) if (bucket[i] !== undefined) masks.push(bucket[i]); outer: for (let replicate = 0; replicate < 2; replicate++) for (const [block, mask] of masks.entries()) for (let shift = 0; shift < 4; shift++) { if (rows.length >= gameLimit) break outer; const direction = replicate === 0 ? 1 : -1; const row = play(rows.length, block, shift, replicate, direction, mask); rows.push(row); if (rows.length % 4 === 0 || row.outcome !== 'complete' || row.rejected) console.log( JSON.stringify({ progress: rows.length, of: gameLimit, replicate, block, mask, players: row.players, last: { outcome: row.outcome, turn: row.turn, accepted: row.accepted, rejected: row.rejected, failure: row.failure, context: row.context, }, elapsedSeconds: Math.round((Date.now() - started) / 1000), }), ); // Preserve the first failing sample and pause this study for a real correction; never mask it as a clean batch. if (row.outcome !== 'complete' || row.rejected) break outer; } const currentSources = sourceFingerprint(); const reasons: Record = {}; for (const row of rows) for (const [reason, count] of Object.entries(row.rejectionReasons)) reasons[reason] = (reasons[reason] ?? 0) + count; const result = { settings: { masterSeed, games: gameLimit, maxActions, output, advanced: true, initializer: 'initializeBaseGameForAudit (shared staged production initializer; public Advanced gate remains closed)', expansions: [], techTokens: false, playerCounts: [2, 3, 4, 5, 6], rosterSubsets: 57, completedRequestedSample: rows.length === gameLimit, replicates: 2, factionDirections: [1, -1], rotatedPhysicalCircles: true, }, rng: 'Mulberry32 engine shuffle stream plus independent seeded UUID stream; each four-shift deal block resets both', sources, sourceFilesChangedDuringRun: sources.combined !== currentSources.combined, finalSources: currentSources, elapsedMs: Date.now() - started, aggregate: statistics(rows), byPlayerCount: Object.fromEntries( [2, 3, 4, 5, 6].map((n) => [ n, statistics(rows.filter((r) => r.players === n)), ]), ), factionAppearances: Object.fromEntries( baseFactions.map((f) => [ f, rows.reduce( (sum, r) => sum + r.seats.filter((s) => s.faction === f).length, 0, ), ]), ), rejectionReasons: Object.fromEntries( Object.entries(reasons).sort((a, b) => b[1] - a[1]), ), rows, }; writeFileSync(output, `${JSON.stringify(result, null, 2)}\n`); console.log( JSON.stringify( { output, elapsedMs: result.elapsedMs, aggregate: result.aggregate, sourceFilesChangedDuringRun: result.sourceFilesChangedDuringRun, }, null, 2, ), ); if ( rows.length !== gameLimit || rows.some((r) => r.outcome !== 'complete' || r.rejected) || result.sourceFilesChangedDuringRun ) process.exitCode = 1; } finally { if (originalUuid) Object.defineProperty(cryptoObject, 'randomUUID', originalUuid); else Reflect.deleteProperty(cryptoObject, 'randomUUID'); if (originalRandom) Object.defineProperty(cryptoObject, 'getRandomValues', originalRandom); else Reflect.deleteProperty(cryptoObject, 'getRandomValues'); }