import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {definitions} from '../tools/definitions.mjs';import {solve,canAppend,violations,hint,assertStructure,doorOpen,directionAt} from '../tools/engine-template.mjs'; const packs=definitions.map(d=>JSON.parse(fs.readFileSync(new URL('../modes/'+d.id+'/levels.json',import.meta.url),'utf8'))); // Independent tiny-board reference: its own geometry and full-path rule checks. function reference(level){const cells=level.cells,at=new Map(cells.map(c=>[c.id,c]));function occupied(c){if(c.shape)return c.shape.map(([x,y])=>[c.x+x,c.y+y]);let u=[];for(let y=0;y<(c.h||1);y++)for(let x=0;x<(c.w||1);x++)u.push([c.x+x,c.y+y]);return u;} const adjacent=(a,b)=>level.geometry==='hex'?[[1,0],[-1,0],[0,1],[0,-1],[1,-1],[-1,1]].some(([q,r])=>b.q-a.q===q&&b.r-a.r===r):occupied(a).some(([x,y])=>occupied(b).some(([xx,yy])=>Math.abs(xx-x)+Math.abs(yy-y)===1)); const adj=new Map(cells.map(c=>[c.id,cells.filter(b=>b!==c&&adjacent(c,b)).map(c=>c.id)]));const answers=[],p=[]; function accept(){const pos=id=>p.indexOf(id)+1;for(const c of cells)if(c.clue&&pos(c.id)!==c.clue)return false;for(const r of level.relations||[])if(pos(r.to)-pos(r.from)!==r.delta)return false;for(const chain of level.orderChains||[])for(let i=1;i=pos(chain[i]))return false;for(const w of level.windows||[])if(pos(w.cell)w.max)return false; for(const c of cells)if(c.direction){const i=pos(c.id)-1;if(i===0||i===p.length-1)return false;const a=at.get(p[i-1]),b=c,d=at.get(p[i+1]);const side=x=>x.xb.x?'r':x.yi)return false;return true;} function walk(id){p.push(id);if(p.length===cells.length){if(accept())answers.push([...p]);}else for(const next of adj.get(id))if(!p.includes(next))walk(next);p.pop();}for(const c of cells)walk(c.id);return answers; } for(const pack of packs)for(const level of pack.levels.slice(0,2))test('independent exhaustive reference '+pack.mode+' '+level.id,()=>{assert.deepEqual(solve(level,{limit:Infinity}),reference(level));}); test('all 40 author routes playable and undo states recomputed',()=>{let n=0;for(const pack of packs)for(const l of pack.levels){assertStructure(l);const p=solve(l)[0];for(let i=0;i{ const coords={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]};for(const kind of ['lr','du','ru','dr','dl','lu']){const cells=[{id:'center',x:0,y:0,direction:kind},...Object.entries(coords).map(([id,[x,y]])=>({id,x,y}))];const l={mode:'direction',geometry:'square',cells};const [a,b]=kind;assert.equal(violations(l,[a,'center',b],{complete:false}).length,0);assert.equal(violations(l,[b,'center',a],{complete:false}).length,0);assert.equal(violations(l,[a,'center'],{complete:false}).length,0);const wrong=Object.keys(coords).find(k=>!kind.includes(k));assert.ok(violations(l,[a,'center',wrong],{complete:false}).length);assert.ok(violations(l,['center',a],{complete:false}).length);assert.ok(violations(l,[a,'center'],{complete:true}).length);} }); test('permanent walls block both directions and never react to visited count',()=>{const l={mode:'walls',geometry:'square',cells:[{id:'a',x:0,y:0},{id:'b',x:1,y:0},{id:'c',x:0,y:1},{id:'d',x:1,y:1}],walls:[['a','b']]};assert.equal(canAppend(l,['a'],'b'),false);assert.equal(canAppend(l,['b'],'a'),false);assert.equal(canAppend(l,['c','d','b'],'a'),false);assert.ok(violations(l,['a','b']).length);}); test('step door starts closed, includes start, toggles and restores after undo',()=>{const d={edge:['b','d'],initialOpen:false};assert.deepEqual([0,1,2,3,4].map(t=>doorOpen(d,t)),[false,true,false,true,false]);const l={mode:'rhythm-door',geometry:'square',cells:[{id:'a',x:0,y:0},{id:'b',x:1,y:0},{id:'c',x:0,y:1},{id:'d',x:1,y:1}],doors:[d]};assert.equal(canAppend(l,['a','b'],'d'),false);assert.equal(canAppend(l,['a','c','d'],'b'),true);assert.equal(canAppend(l,['b'],'d'),true);assert.equal(doorOpen(d,0),false);}); test('dynamic connectivity retains future door opportunities and static walls cannot replace it',()=>{const pack=packs.find(p=>p.mode==='rhythm-door');for(const l of pack.levels){assert.deepEqual(solve(l,{prune:true}),solve(l,{prune:false}));const copy=structuredClone(l);copy.walls=copy.doors.map(d=>d.edge);delete copy.doors;assert.notEqual(solve(copy).length,1);}}); test('hex fusion needs each permanent wall and the alternating rule',()=>{const pack=packs.find(p=>p.mode==='hex-alternation');assert.ok(pack.levels[6].cells.some(c=>c.r===2));for(const l of pack.levels){assert.ok(l.walls.length);assert.ok(solve(l,{omit:'alternation'}).length>=2);for(let i=0;i=2);}}); test('alternating classes are hard transitions; whole block counts once',()=>{const l=packs.find(p=>p.mode==='block-alternation').levels[0];const p=solve(l)[0];assert.equal(p.length,l.cells.length);const g=l.cells.find(c=>c.w>1);assert.ok(g);const target=l.cells.find(c=>c.category===g.category&&c.id!==g.id);assert.equal(canAppend(l,[g.id],target.id),false);}); test('reject overlap, cycles and nonexistent edges',()=>{assert.throws(()=>assertStructure({mode:'order',geometry:'square',cells:[{id:'a',x:0,y:0},{id:'b',x:0,y:0},{id:'c',x:1,y:0}]}));const l={mode:'order',geometry:'square',cells:[0,1,2].map(i=>({id:''+i,x:i,y:0})),orderChains:[['0','1'],['1','0']]};assert.throws(()=>assertStructure(l));delete l.orderChains;l.gates=[{edge:['0','2'],switchCell:'1'}];assert.throws(()=>assertStructure(l));});