import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {solve,violations,canAppend,assertStructure,doorOpen,doorLatched,outlineUnits,hint} from '../rules.mjs'; const pack=JSON.parse(fs.readFileSync(new URL('../levels.json',import.meta.url),'utf8')); function reference(l){const at=new Map(l.cells.map(c=>[c.id,c]));function unit(c){if(c.shape)return c.shape.map(([x,y])=>[c.x+x,c.y+y]);let a=[];for(let y=0;y<(c.h||1);y++)for(let x=0;x<(c.w||1);x++)a.push([c.x+x,c.y+y]);return a;}const adjacency=(a,b)=>l.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):unit(a).some(([x,y])=>unit(b).some(([xx,yy])=>Math.abs(x-xx)+Math.abs(y-yy)===1));const adj=new Map(l.cells.map(c=>[c.id,l.cells.filter(b=>b!==c&&adjacency(c,b)).map(c=>c.id)]));const result=[],p=[]; function valid(){const pos=id=>p.indexOf(id)+1;for(const c of l.cells){if(c.clue!==undefined&&pos(c.id)!==c.clue)return false;if(c.parity&&(pos(c.id)%2===1)!==(c.parity==='odd'))return false;if(c.direction){const k=pos(c.id)-1;if(k===0||k===p.length-1)return false;const b=c;const side=a=>a.xb.x?'r':a.ye.includes(p[i-1])&&e.includes(p[i])))return false;for(const d of l.doors||[])if(d.edge.includes(p[i-1])&&d.edge.includes(p[i])&&!(Boolean(d.initialOpen)!==Boolean(i%2)))return false;}return true;} function walk(id){const c=at.get(id),step=p.length+1;if(c.clue!==undefined&&c.clue!==step)return;p.push(id);if(p.length===l.cells.length){if(valid())result.push([...p]);}else for(const id of adj.get(p.at(-1)))if(!p.includes(id))walk(id);p.pop();}for(const c of l.cells)walk(c.id);return result;} for(const order of [1,2,3,5,6,11,13,16,18,21,22,24,26,28,31,33,37])test('independent reference order '+order,()=>{const l=pack.levels[order-1];const a=solve(l,{limit:Infinity}),b=reference(l);assert.deepEqual(a.map(p=>p.join()).sort(),b.map(p=>p.join()).sort());}); test('parity step check and reserved numeric clue coexist on different cells',()=>{const l={mode:'test',features:['parity'],geometry:'square',cells:[{id:'a',x:0,y:0,clue:1},{id:'b',x:1,y:0,parity:'even'},{id:'c',x:2,y:0}]};assert.equal(violations(l,['a','b']).length,0);assert.ok(violations(l,['b']).length);l.cells[1].parity='odd';assert.ok(violations(l,['a','b']).length);l.cells[1].clue=2;assert.throws(()=>assertStructure(l));}); test('alternation driven by feature, never by mode name',()=>{const l={mode:'campaign-50',features:['alternation'],geometry:'square',cells:[{id:'a',x:0,y:0,category:'circle'},{id:'b',x:1,y:0,category:'circle'},{id:'c',x:2,y:0,category:'triangle'}]};assert.equal(canAppend(l,['a'],'b'),false);l.features=[];assert.equal(canAppend(l,['a'],'b'),true);}); test('six port pairs, reverse traversal and deferred departure check',()=>{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 l={mode:'test',features:['direction'],geometry:'square',cells:[{id:'p',x:0,y:0,direction:kind},...Object.entries(coords).map(([id,[x,y]])=>({id,x,y}))]};const [a,b]=kind;assert.equal(violations(l,[a,'p'],{complete:false}).length,0);assert.equal(violations(l,[a,'p',b],{complete:false}).length,0);assert.equal(violations(l,[b,'p',a],{complete:false}).length,0);assert.ok(violations(l,['p',a],{complete:false}).length);assert.ok(violations(l,[a,'p'],{complete:true}).length);}}); test('door includes first landing and undo phase',()=>{const d={edge:['a','b'],initialOpen:false};assert.deepEqual([0,1,2,3,4].map(n=>doorOpen(d,n)),[false,true,false,true,false]);}); test('all campaign paths play with proper hints and gate pruning',()=>{for(const l of pack.levels){const p=solve(l)[0];for(let i=0;i{const l=structuredClone(pack.levels[20]);l.features.push('alternation');for(const [i,c]of l.cells.entries())c.category=i%2?'circle':'triangle';assert.doesNotThrow(()=>assertStructure(l));}); test('legal door crossing latches until that crossing is removed',()=>{const d={edge:['a','b'],initialOpen:false};assert.equal(doorLatched(d,['a']),false);assert.equal(doorOpen(d,['a','b']),true);assert.equal(doorOpen(d,['a','b','c','d']),true);assert.equal(doorLatched(d,['a','b','c']),true);assert.equal(doorLatched(d,['a']),false);assert.equal(doorOpen(d,[]),false);assert.equal(doorLatched(d,['x','a','b']),false);}); test('unit hex colour and parity levels do not require large blocks',()=>{for(const feature of ['alternation','parity'])assert.ok(pack.levels.some(l=>l.geometry==='hex'&&l.features.includes(feature)&&!l.features.includes('big')));}); test('whole block outline contains no internal shared edges',()=>{for(const c of [{x:0,y:0,w:2,h:2},{x:0,y:0,shape:[[0,0],[1,0],[0,1]]},{x:0,y:0,shape:[[0,0],[1,0],[2,0],[1,1]]}]){const contour=outlineUnits(c);const area=contour.reduce((s,a,i)=>{const b=contour[(i+1)%contour.length];return s+a[0]*b[1]-a[1]*b[0];},0)/2;assert.equal(area,c.shape?c.shape.length:c.w*c.h);assert.ok(contour.length>=4);}});