Files
HexTrails/scripts/analyze-v01-design.mjs
T
cabinw e92cccba82 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.
2026-09-09 13:17:30 +08:00

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}); }