109 lines
5.9 KiB
JavaScript
109 lines
5.9 KiB
JavaScript
// Research-only audit. Uses the project's actual boards and solvers; does not edit levels.
|
|
// Run from the repository root: node docs/research/audit-level-structure.mjs
|
|
// Requires Node.js 24 with native TypeScript stripping and registerHooks.
|
|
import { registerHooks } from 'node:module';
|
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
const root = new URL('../../', import.meta.url);
|
|
registerHooks({
|
|
resolve(specifier, context, nextResolve) {
|
|
if (specifier.startsWith('.') && context.parentURL?.endsWith('.ts') && !/\.[a-z]+$/i.test(specifier)) {
|
|
const candidate = new URL(specifier + '.ts', context.parentURL);
|
|
if (existsSync(candidate)) return nextResolve(candidate.href, context);
|
|
}
|
|
return nextResolve(specifier, context);
|
|
},
|
|
});
|
|
|
|
function bipartite(adj) {
|
|
const colors = Array(adj.length).fill(-1);
|
|
for (let i = 0; i < adj.length; i++) {
|
|
if (colors[i] !== -1) continue;
|
|
colors[i] = 0;
|
|
const queue = [i];
|
|
for (let k = 0; k < queue.length; k++) {
|
|
const u = queue[k];
|
|
for (const v of adj[u]) {
|
|
if (colors[v] === colors[u]) return false;
|
|
if (colors[v] === -1) { colors[v] = 1 - colors[u]; queue.push(v); }
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const levels = [];
|
|
for (const mode of ['01-number-stroke', '03-hex-walls']) {
|
|
const numeric = mode === '01-number-stroke';
|
|
const loader = await import(new URL(`modes/${mode}/src/levels.ts`, root));
|
|
const solver = await import(new URL(`modes/${mode}/src/solver.ts`, root));
|
|
const folder = new URL(`modes/${mode}/levels/`, root);
|
|
for (const file of readdirSync(folder).filter(f => f.endsWith('.json')).sort()) {
|
|
const pack = loader.parsePack(JSON.parse(readFileSync(new URL(file, folder), 'utf8')));
|
|
for (const { def, board: b } of loader.loadPack(pack)) {
|
|
const solutions = solver.solve(b, { limit: 2 });
|
|
const path = solutions.length === 1 ? solutions[0] : null;
|
|
const clues = b.cells.filter(c => c.clue !== null).map(c => c.clue).sort((a,z) => a-z);
|
|
const gaps = clues.slice(1).map((c,i) => c-clues[i]);
|
|
if (clues.length) gaps.push(b.n-clues.at(-1));
|
|
const branches = [];
|
|
if (path) {
|
|
const visited = new Set();
|
|
for (let t = 1; t < path.length; t++) {
|
|
visited.add(path[t-1]);
|
|
const adj = numeric ? b.adj[path[t-1]] : b.adjAt(path[t-1],t);
|
|
const physical = adj.filter(v => !visited.has(v));
|
|
const immediate = physical.filter(v => {
|
|
const c = b.cells[v], nextStep=t+1;
|
|
if (b.clueCellOfStep.has(nextStep) && b.clueCellOfStep.get(nextStep) !== v) return false;
|
|
if (c.clue !== null && c.clue !== nextStep) return false;
|
|
if (numeric && c.parity && (nextStep%2===1 ? 'odd' : 'even') !== c.parity) return false;
|
|
return true;
|
|
});
|
|
if (immediate.length > 1) branches.push({step:t, choices:immediate.map(v=>b.cells[v].id)});
|
|
}
|
|
}
|
|
const geometry = numeric ? b.cells.map(c=>c.units.map(([x,y])=>`${x},${y}`).sort().join(';')).sort() : (()=> {
|
|
const minQ=Math.min(...b.cells.map(c=>c.q)), minR=Math.min(...b.cells.map(c=>c.r));
|
|
return b.cells.map(c=>`${c.q-minQ},${c.r-minR}`).sort();
|
|
})();
|
|
levels.push({
|
|
mode, pack:pack.pack, title:pack.title, id:def.id, cells:b.n, clues,
|
|
maxClueGap:gaps.length ? Math.max(...gaps) : null, hasStart:clues.includes(1),
|
|
hasEnd:clues.includes(b.n), parityCount:numeric ? b.cells.filter(c=>c.parity).length : 0,
|
|
bigCells:numeric ? b.cells.filter(c=>c.units.length>1).length : 0,
|
|
shapeFields:numeric ? def.cells.filter(c=>c.shape).length : 0,
|
|
staticWalls:numeric ? 0 : b.walls.length, spinners:numeric ? 0 : b.spinners.length,
|
|
graphBipartite:bipartite(b.adj),
|
|
solutionCountCappedAtTwo:solutions.length,
|
|
prefixBranchPoints:branches.length, maxImmediateChoices:Math.max(1,...branches.map(b=>b.choices.length)),
|
|
branchDetails:branches, solution:path?.map(v=>b.cells[v].id) ?? null,
|
|
geometryKey:createHash('sha256').update(JSON.stringify(geometry)).digest('hex').slice(0,16),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const range=(items,key)=>[Math.min(...items.map(x=>x[key])),Math.max(...items.map(x=>x[key]))];
|
|
const groups=[];
|
|
for (const mode of ['01-number-stroke', '03-hex-walls']) {
|
|
for (const pack of [...new Set(levels.filter(x=>x.mode===mode).map(x=>x.pack))]) {
|
|
const list=levels.filter(x=>x.mode===mode && x.pack===pack);
|
|
groups.push({mode,pack,title:list[0].title,count:list.length,cellsRange:range(list,'cells'),
|
|
clueCountRange:[Math.min(...list.map(x=>x.clues.length)),Math.max(...list.map(x=>x.clues.length))],
|
|
maxClueGapRange:range(list,'maxClueGap'),branchRange:range(list,'prefixBranchPoints'),
|
|
unknownStart:list.filter(x=>!x.hasStart).map(x=>x.id),nonBipartite:list.filter(x=>!x.graphBipartite).length,
|
|
spinnerRange:range(list,'spinners'),unique:list.filter(x=>x.solutionCountCappedAtTwo===1).length});
|
|
}
|
|
}
|
|
const reused=[];
|
|
for (const key of new Set(levels.map(x=>x.mode+':'+x.geometryKey))) {
|
|
const list=levels.filter(x=>x.mode+':'+x.geometryKey===key);
|
|
if (list.length>1) reused.push({mode:list[0].mode,levels:list.map(x=>x.id)});
|
|
}
|
|
const output={auditDate:'2026-10-14',nodeVersion:process.version,
|
|
method:'Original board models and solve(limit=2). Branch count follows unique solution prefix, checks physical reachability and immediate exact/parity clues only; excludes unknown-start choices. Not a human difficulty score. Hex bipartite uses static-wall graph with rotating walls ignored. Geometry reuse ignores clues/walls/phases, compares translation only.',
|
|
total:levels.length, unique:levels.filter(x=>x.solutionCountCappedAtTwo===1).length,groups,reusedGeometries:reused,levels};
|
|
writeFileSync(new URL('level-structure-audit.json',import.meta.url),JSON.stringify(output,null,2)+'\n','utf8');
|
|
console.log(JSON.stringify({total:output.total,unique:output.unique,groups,reusedGeometries:reused},null,2));
|