纠正仓库内容为衍生项目,标明 campaign-200-r2 最新版

This commit is contained in:
toshiocn
2026-10-19 16:56:31 +08:00
parent a492c5b80e
commit fdca4f67ea
1396 changed files with 392768 additions and 19530 deletions
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import vm from 'node:vm';import {definitions} from '../tools/definitions.mjs';import {solve,graph} from '../tools/engine-template.mjs';
function boot(mode,width=340){const draws=[],elements=new Map();let context;
class Element{constructor(id=''){this.id=id;this.children=[];this.handlers={};this.hidden=false;this.disabled=false;this.value='';this.textContent='';this.className='';}replaceChildren(){this.children=[];}append(e){this.children.push(e);}addEventListener(n,f){this.handlers[n]=f;}setPointerCapture(){}getBoundingClientRect(){return {left:0,top:0,width,height:420};}click(){this.onclick?.();}}
const ctx={globalAlpha:1,lineWidth:1,font:'',fillStyle:'',strokeStyle:'',textAlign:'center',textBaseline:'middle',setTransform(){},clearRect(){draws.length=0;},beginPath(){this.p=[];},moveTo(x,y){this.p.push([x,y]);},lineTo(x,y){this.p.push([x,y]);},closePath(){},fill(){draws.push({kind:'fill',points:this.p,color:this.fillStyle});},stroke(){draws.push({kind:'stroke',points:this.p,color:this.strokeStyle});},fillText(s,x,y){draws.push({kind:'text',text:s,x,y,font:this.font});},save(){},restore(){},translate(){},rotate(){},setLineDash(){}};
function get(id){if(!elements.has(id))elements.set(id,new Element(id));return elements.get(id);}
const canvas=get('board');canvas.clientWidth=width;canvas.clientHeight=420;canvas.getContext=()=>ctx;
const document={getElementById:get,createElement:()=>new Element()},window={devicePixelRatio:1,addEventListener(){}};
context=vm.createContext({document,window,localStorage:{getItem:()=>null,setItem(){}},performance:{now:()=>100},setTimeout,URL,Blob,console});
const html=fs.readFileSync(new URL('../modes/'+mode+'/play.html',import.meta.url),'utf8');const script=html.match(/<script>([\s\S]*)<\/script>/)[1];new vm.Script(script).runInContext(context);
const snapshot=()=>JSON.parse(JSON.stringify(window.variantSnapshot()));
function press(id){const p=snapshot().centers[id];canvas.handlers.pointerdown({preventDefault(){},pointerId:1,clientX:p[0],clientY:p[1]});canvas.handlers.pointerup({});}
return {get,snapshot,press,canvas,draws,width};
}
for(const def of definitions)test('embedded page lifecycle and all 8 levels '+def.id,()=>{const ui=boot(def.id);const pack=JSON.parse(fs.readFileSync(new URL('../modes/'+def.id+'/levels.json',import.meta.url),'utf8'));
for(let i=0;i<8;i++){ui.get('difficulties').children[Math.floor(i/2)].click();ui.get('levels').children[i%2].click();const l=pack.levels[i],p=solve(l)[0];assert.equal(ui.snapshot().level,l.id);assert.equal(ui.get('levels').children.length,2);assert.equal(ui.get('difficulties').children.length,4);
const before=ui.snapshot();for(const center of Object.values(before.centers)){assert.ok(center[0]>=0&&center[0]<=ui.width);assert.ok(center[1]>=0&&center[1]<=420);}assert.ok(before.unit>=20);
ui.get('hint').click();assert.match(ui.get('status').textContent,/黄色/);
ui.press(p[0]);ui.press(p[1]);assert.deepEqual(ui.snapshot().path,p.slice(0,2));ui.press(p[0]);assert.deepEqual(ui.snapshot().path,p.slice(0,1));ui.get('undo').click();assert.deepEqual(ui.snapshot().path,[]);
for(const id of p)ui.press(id);assert.equal(ui.snapshot().cleared,true);assert.equal(ui.get('remaining').textContent,'完成 ✓');
ui.get('undo').click();assert.equal(ui.snapshot().cleared,false);assert.equal(ui.snapshot().path.length,p.length-1);ui.press(p.at(-1));assert.equal(ui.snapshot().cleared,true);
ui.get('restart').click();assert.deepEqual(ui.snapshot().path,[]);assert.equal(ui.snapshot().cleared,false);assert.ok(ui.snapshot().openedGates.every(v=>!v));
if(l.geometry==='blocks')assert.ok(l.cells.some(c=>c.w>1));
}
});
test('fast drag samples crossed cells and preserves prefix after release',()=>{const ui=boot('walls'),p=solve(JSON.parse(fs.readFileSync(new URL('../modes/walls/levels.json',import.meta.url),'utf8')).levels[0])[0];const start=ui.snapshot().centers[p[0]],next=ui.snapshot().centers[p[1]];ui.canvas.handlers.pointerdown({preventDefault(){},pointerId:1,clientX:start[0],clientY:start[1]});ui.canvas.handlers.pointermove({preventDefault(){},clientX:next[0],clientY:next[1]});ui.canvas.handlers.pointerup({});assert.deepEqual(ui.snapshot().path,p.slice(0,2));});
test('step door UI shows current and next state and undo/restart restore it',()=>{const ui=boot('rhythm-door'),l=JSON.parse(fs.readFileSync(new URL('../modes/rhythm-door/levels.json',import.meta.url),'utf8')).levels[0],p=solve(l)[0];assert.deepEqual(ui.snapshot().doors,[false]);assert.match(ui.get('constraints').children[0].textContent,/门关.*就开/);ui.press(p[0]);assert.deepEqual(ui.snapshot().doors,[true]);assert.match(ui.get('constraints').children[0].textContent,/门开.*就关/);ui.press(p[1]);assert.deepEqual(ui.snapshot().doors,[false]);ui.get('undo').click();assert.deepEqual(ui.snapshot().doors,[true]);ui.get('restart').click();assert.deepEqual(ui.snapshot().doors,[false]);});
test('alternation keeps blue/orange fill after visits and uses clear colour legend',()=>{for(const mode of ['block-alternation','hex-alternation']){const ui=boot(mode),l=JSON.parse(fs.readFileSync(new URL('../modes/'+mode+'/levels.json',import.meta.url),'utf8')).levels[0],p=solve(l)[0];const colours=()=>ui.draws.filter(d=>d.kind==='fill').map(d=>d.color);assert.ok(colours().includes('#64acf3'));assert.ok(colours().includes('#ffae55'));ui.press(p[0]);ui.press(p[1]);assert.ok(colours().includes('#64acf3'));assert.ok(colours().includes('#ffae55'));assert.match(ui.get('constraints').children[0].textContent,/蓝.*橙.*换颜色/);}});
test('retired modes are absent from the active launcher',()=>{const html=fs.readFileSync(new URL('../index.html',import.meta.url),'utf8');for(const id of ['relative','order','window','gates'])assert.ok(!html.includes('modes/'+id+'/'));assert.ok(html.includes('modes/walls/'));assert.ok(html.includes('modes/rhythm-door/'));});
+25
View File
@@ -0,0 +1,25 @@
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));});