feat: version campaigns and document original puzzle design
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.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
// 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}); }
|
||||
Reference in New Issue
Block a user