Files
NumberSwipe_New/tests/rules.test.mjs
T

26 lines
6.4 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,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<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 side=x=>x.x<b.x?'l':x.x>b.x?'r':x.y<b.y?'u':'d';if([side(a),side(d)].sort().join('')!==c.direction)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 w of level.walls||[])for(let i=1;i<p.length;i++)if(w.includes(p[i-1])&&w.includes(p[i]))return false;
for(const door of level.doors||[])for(let i=1;i<p.length;i++)if(door.edge.includes(p[i-1])&&door.edge.includes(p[i])&&!(Boolean(door.initialOpen)!==Boolean(i%2)))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 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<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,40);});
test('six exact channel orientations are reversible and reject other ports',()=>{
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<l.walls.length;i++)assert.ok(solve(l,{omit:'wall:'+i}).length>=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));});