// Read-only campaign audit: pin the original engine so later rule/scoring edits // cannot silently change our account of the first 24 levels. import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const root=fileURLToPath(new URL('../',import.meta.url)); const commit='ef2724bb2e6582c92ac26b3a45fcc18c406d6c21'; const hash=data=>createHash('sha256').update(data).digest('hex'); const scratch=await mkdtemp(join(tmpdir(),'hex-v01-design-')); try { const engineSources=[]; await writeFile(join(scratch,'package.json'),'{"type":"module"}\n'); for(const path of ['src/core.js','src/path-math.js']){ const bytes=execFileSync('git',['show',`${commit}:${path}`],{cwd:root}); await writeFile(join(scratch,path.split('/').at(-1)),bytes); engineSources.push({path,sha256:hash(bytes)}); } const {analyze,trace,Game,available,opposite,validateLevel}=await import(pathToFileURL(join(scratch,'core.js'))); const bendCount=route=>route.steps.filter(s=>s.enter!==null&&opposite(s.enter)!==s.exit).length; const auditLevel=(level,index)=>{ const errors=validateLevel(level);if(errors.length)throw new Error(`${level.name}: ${errors.join('; ')}`); const a=analyze(level),game=new Game({...level,moveLimit:999}); if(!a.reachable||a.exhausted)throw new Error(`Unconfirmed layout: ${level.name}`); const opening=level.tiles.map(t=>{ const route=trace(level,t),solo=trace(level,t,level.tiles,true); const blocker=level.tiles.find(b=>b.id===route.blocker); const sweptOnly=route.status==='blocked'&&blocker&&!solo.steps.some(s=>s.q===blocker.q&&s.r===blocker.r); return {id:t.id,q:t.q,r:t.r,dir:t.dir,status:route.status,blocker:route.blocker,independentLoop:solo.status==='loop',sweptOnly:!!sweptOnly}; }); const removals=[],rotations=[]; for(const action of a.actions){ if(action.type==='rotate'){ const before=available(game.level,game.tiles).map(t=>t.id); if(game.rotate(action.direction).status!=='rotated')throw new Error('Failed rotation replay'); rotations.push({direction:action.direction,remaining:game.tiles.map(t=>t.id),before,after:available(game.level,game.tiles).map(t=>t.id)}); }else{ const route=game.tap(action.id);if(route.status!=='exit')throw new Error(`Failed removal: ${level.name}`); removals.push({id:action.id,orientation:game.orientation,bends:bendCount(route),cells:route.steps.length}); } } if(!game.won)throw new Error('Incomplete replay'); return { level:index+1,id:level.id,name:level.name,cells:level.cells.length,tiles:level.tiles.length, occupancy:level.tiles.length/level.cells.length,originalScore:a.score,originalMoveLimit:level.moveLimit, originalMinimumMoves:a.minimumMoves,originalBudgetSolvable:a.solvable,minimumRotations:a.rotations, free:a.free,blocked:opening.filter(p=>p.status==='blocked').length,initialIndependentLoops:opening.filter(p=>p.independentLoop).length, initialSweptOnlyBlockers:opening.filter(p=>p.sweptOnly).length,meanIndependentTurns:a.meanTurns, removalWaves:a.depth,opening,actions:a.actions,waves:a.waves,rotations,removals, curvedRemovals:removals.filter(p=>p.bends>0).length,totalSuccessfulBends:removals.reduce((s,p)=>s+p.bends,0), }; }; const sources=[['v0.1','levels/versions/v0.1.json'],['v0.2','levels/versions/v0.2.json'],['v0.3','levels/campaign.json']]; const editions=[]; for(const [version,path] of sources){ const bytes=await readFile(join(root,path)),levels=JSON.parse(bytes),rows=levels.map(auditLevel); if(version==='v0.1'){ const historical=execFileSync('git',['show',`${commit}:levels/campaign.json`],{cwd:root}); if(!bytes.equals(historical)||rows.some(r=>!r.originalBudgetSolvable))throw new Error('Original layout or historical budget verification failed'); } const sum=field=>rows.reduce((s,r)=>s+r[field],0),bounds=field=>[Math.min(...rows.map(r=>r[field])),Math.max(...rows.map(r=>r[field]))]; editions.push({version,path,sha256:hash(bytes),count:rows.length, summary:{cells:[...new Set(rows.map(r=>r.cells))].sort((a,b)=>a-b),tiles:bounds('tiles'),totalTiles:sum('tiles'), requiresRotation:rows.filter(r=>r.minimumRotations>0).length,rotationRange:bounds('minimumRotations'),rotationCounts:Object.fromEntries([0,1,2,3].map(n=>[n,rows.filter(r=>r.minimumRotations===n).length])), initialFree:sum('free'),initialFreeRatio:sum('free')/sum('tiles'),zeroFreeLevels:rows.filter(r=>!r.free).map(r=>r.level), initialIndependentLoops:sum('initialIndependentLoops'),initialSweptOnlyBlockers:sum('initialSweptOnlyBlockers'), removalWaves:bounds('removalWaves'),curvedRemovals:sum('curvedRemovals'),curvedRatio:sum('curvedRemovals')/sum('tiles'), totalSuccessfulBends:sum('totalSuccessfulBends'),originalScores:version==='v0.1'?bounds('originalScore'):undefined, originalScoresNondecreasing:version==='v0.1'?rows.every((r,i)=>!i||r.originalScore>=rows[i-1].originalScore):undefined}, levels:version==='v0.1'?rows:undefined}); } const report={basis:{commit,engineSources, note:'All geometry, opening states and reference solutions use the pinned original engine. All reference replays use budget 999 to compare route structures; v0.1 additionally verifies its original move budget. Original scores and move budgets are interpreted only for v0.1; reference route usage is not all possible player orders. No player telemetry.'},editions}; await writeFile(join(root,'docs/v0.1-design-evidence.json'),JSON.stringify(report,null,2)+'\n'); for(const edition of editions)console.log(edition.version,JSON.stringify(edition.summary)); console.log('Original 24:',editions[0].levels.map(r=>`${r.level} ${r.name}: ${r.cells}/${r.tiles}, free ${r.free}, turns ${r.minimumRotations}, waves ${r.removalWaves}, score ${r.originalScore}`).join('\n')); } finally { await rm(scratch,{recursive:true,force:true}); }