18 lines
5.4 KiB
JavaScript
18 lines
5.4 KiB
JavaScript
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.x<b.x?'l':a.x>b.x?'r':a.y<b.y?'u':'d';if([side(at.get(p[k-1])),side(at.get(p[k+1]))].sort().join('')!==c.direction)return false;}}
|
|
for(let i=1;i<p.length;i++){if(l.features.includes('alternation')&&at.get(p[i-1]).category===at.get(p[i]).category)return false;if((l.walls||[]).some(e=>e.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<p.length;i++)assert.equal(canAppend(l,p.slice(0,i),p[i]),true);assert.equal(hint(p,[]).cell,p[0]);assert.equal(hint(p,p.slice(0,2)).cell,p[2]);assert.equal(hint(p,p).kind,'none');if(l.features.includes('rhythm-door'))assert.deepEqual(solve(l,{prune:true}),solve(l,{prune:false}));}});
|
|
test('parity and colour can combine when geometry and markers are valid',()=>{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);}});
|