Files
NumberSwipe_New/docs/research/audit-level-structure.mjs
T

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