Restore v0.1 and v0.2 archives with separate progress, publish the curved v0.3 campaign, and document the original 24-level design with reproducible evidence. Refine board presentation and movement, and keep video lab tooling within its own directory.
81 lines
6.0 KiB
JavaScript
81 lines
6.0 KiB
JavaScript
// 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}); }
|