22 lines
5.3 KiB
JavaScript
22 lines
5.3 KiB
JavaScript
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} 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<chain.length;i++)if(pos(chain[i-1])>=pos(chain[i]))return false;for(const w of level.windows||[])if(pos(w.cell)<w.min||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 straight=(a.x===b.x&&b.x===d.x)||(a.y===b.y&&b.y===d.y);if(straight!==(c.direction==='straight'))return false;}
|
|
if(level.mode.includes('alternation'))for(let i=1;i<p.length;i++)if(at.get(p[i-1]).category===at.get(p[i]).category)return false;
|
|
for(const g of level.gates||[])for(let i=1;i<p.length;i++)if(g.edge.includes(p[i-1])&&g.edge.includes(p[i])&&pos(g.switchCell)>i)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 56 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<p.length;i++)assert.equal(canAppend(l,p.slice(0,i),p[i]),true);assert.deepEqual(violations(l,p),[]);const wrong=[...p];[wrong[0],wrong[1]]=[wrong[1],wrong[0]];assert.ok(['restart','backtrack'].includes(hint(p,wrong).kind));assert.equal(hint(p,p.slice(0,2)).cell,p[2]);assert.equal(hint(p,p).kind,'none');n++;}assert.equal(n,56);});
|
|
test('relative difference is transitions, not intermediate count',()=>{const l={mode:'relative',geometry:'square',cells:[0,1,2,3,4].map(i=>({id:''+i,x:i,y:0,w:1,h:1})),relations:[{from:'0',to:'4',delta:4}]};assert.equal(violations(l,['0','1','2','3','4']).length,0);l.relations[0].delta=3;assert.ok(violations(l,['0','1','2','3','4']).length);});
|
|
test('window inclusive edges, expiration and shape rejection',()=>{const l={mode:'window',geometry:'square',cells:[0,1,2,3,4].map(i=>({id:''+i,x:i,y:0})),windows:[{cell:'2',min:2,max:4}]};assert.equal(violations(l,['0','2']).length,0);assert.equal(violations(l,['0','1','3','2']).length,0);assert.ok(violations(l,['0','1','3','4']).length);l.windows[0].max=2;assert.throws(()=>assertStructure(l));});
|
|
test('direction waits for exit, excludes both endpoints',()=>{const l={mode:'direction',geometry:'square',cells:[{id:'a',x:0,y:0},{id:'b',x:1,y:0,direction:'turn'},{id:'c',x:1,y:1}]};assert.equal(violations(l,['a','b']).length,0);assert.equal(violations(l,['a','b','c']).length,0);assert.ok(violations(l,['b']).length);assert.ok(violations(l,['a','c','b']).length);});
|
|
test('closed gate cannot open itself on arrival; undo closes it',()=>{const l={mode:'gates',geometry:'square',cells:[{id:'a',x:0,y:0},{id:'b',x:1,y:0},{id:'s',x:0,y:1},{id:'d',x:1,y:1}],gates:[{edge:['a','b'],switchCell:'s'}]};assert.equal(canAppend(l,['a'],'b'),false);assert.equal(canAppend(l,['s','d','a'],'b'),true);assert.equal(canAppend(l,['a'],'b'),false);l.gates[0].switchCell='b';assert.equal(canAppend(l,['a'],'b'),false);});
|
|
test('dynamic connectivity retains future door opportunities',()=>{const l=packs.find(p=>p.mode==='gates').levels[6];assert.deepEqual(solve(l,{prune:true}),solve(l,{prune:false}));});
|
|
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));});
|