纠正仓库内容为衍生项目,标明 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
+5
View File
@@ -0,0 +1,5 @@
import test from 'node:test';import assert from 'node:assert/strict';import {CasualAudio} from '../audio.mjs';import {CasualAudio as OriginalAudio} from '../../campaign-50/audio.mjs';
function host(){const events=[];class Audio{constructor(){this.currentTime=1;this.state='running';this.destination={};}createOscillator(){let type;return {set type(v){type=v;},frequency:{setValueAtTime(f,t){events.push({f,t,type});}},connect(){},disconnect(){},start(){},stop(){}};}createGain(){return {gain:{setValueAtTime(){},linearRampToValueAtTime(){},exponentialRampToValueAtTime(){}},connect(){},disconnect(){}};}}return {AudioContext:Audio,events};}const storage={getItem(){return null},setItem(){}};
test('every movement is one semitone higher, including rapid moves; undo/restart follow step index',()=>{const h=host(),a=new CasualAudio(h,storage,'sound');for(let step=1;step<=20;step++)a.play('move',0,step);assert.equal(h.events.length,20);assert.equal(h.events[0].f,523);for(let i=1;i<20;i++)assert.ok(Math.abs(h.events[i].f/h.events[i-1].f-Math.pow(2,1/12))<1e-10);a.play('move',0,7);assert.equal(h.events.at(-1).f,h.events[6].f);a.play('move',0,1);assert.equal(h.events.at(-1).f,523);assert.ok(h.events.every(e=>e.type==='sine'));});
test('other five cues retain exact frequency and timing arrays',()=>{for(const kind of ['correct','error','undo','restart','complete']){const h=host(),old=host();new CasualAudio(h,storage,'s').play(kind);new OriginalAudio(old,storage,'s').play(kind);assert.deepEqual(h.events,old.events,kind);}});
test('silent/unsupported audio never blocks play',()=>{const h=host(),a=new CasualAudio(h,storage,'s');a.toggle();a.play('move',0,5);assert.equal(h.events.length,0);assert.doesNotThrow(()=>new CasualAudio({},storage,'s').play('move'));});
+8
View File
@@ -0,0 +1,8 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {graph,sideBetween,directionAt,violations,solve,canAppend,doorOpen,doorLatched} from '../rules.mjs';
const pack=JSON.parse(fs.readFileSync(new URL('../levels.json',import.meta.url)));
test('a large neighbor above the channel uses its shared upper side rather than its left coordinate',()=>{const l={geometry:'blocks',features:['big','direction'],cells:[{id:'above',x:0,y:0,w:2,h:1},{id:'channel',x:1,y:1,w:1,h:1,direction:'du'},{id:'below',x:1,y:2,w:1,h:1}]};assert.equal(sideBetween(l,'channel','above'),'u');assert.equal(directionAt(l,['above','channel','below'],1),'du');assert.equal(violations(l,['above','channel','below']).length,0);});
test('all rule combinations use shared solver and append logic',()=>{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(violations(l,p).length,0);}});
test('door remains latched until crossing removed',()=>{const d={edge:['a','b'],initialOpen:false};assert.equal(doorOpen(d,[]),false);assert.equal(doorOpen(d,['a']),true);assert.equal(doorOpen(d,['a','b','c','d']),true);assert.equal(doorLatched(d,['a','b']),true);assert.equal(doorLatched(d,['a']),false);});
// Independent exhaustive adjacency+rule enumeration for the smallest board of each mechanism family.
function enumerate(l){const g=graph(l),results=[],p=[];function walk(id){p.push(id);if(p.length===l.cells.length){if(!violations(l,p).length)results.push([...p]);}else for(const j of g.adj[g.ids.get(id)]){const n=l.cells[j].id;if(!p.includes(n))walk(n);}p.pop();}for(const c of l.cells)walk(c.id);return results;}
test('small boards exhaustively enumerate all starts without pruning',()=>{const candidates=pack.levels.filter(l=>l.cells.length<=8);for(const l of candidates){const expected=enumerate(l).map(p=>p.join()).sort();const actual=solve(l,{limit:Infinity}).map(p=>p.join()).sort();assert.deepEqual(actual,expected,'level '+l.order);}});
+11
View File
@@ -0,0 +1,11 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import vm from 'node:vm';import {solve,graph,canAppend,violations} from '../rules.mjs';
const pack=JSON.parse(fs.readFileSync(new URL('../levels.json',import.meta.url),'utf8'));
function boot(width=340,storage=new Map(),height=420,withAudio=false){const map=new Map(),draws=[],downloads=[];class Element{constructor(id=''){this.id=id;this.children=[];this.handlers={};this.value='';this.style={};this.className='';this.textContent='';this.hidden=false;this.disabled=false;}replaceChildren(){this.children=[];}append(...e){this.children.push(...e);}addEventListener(k,f){this.handlers[k]=f;}setPointerCapture(){}getBoundingClientRect(){return {left:0,top:0,width,height};}click(){this.onclick?.();if(this.download)downloads.push(this.download);}}
const get=id=>{if(!map.has(id))map.set(id,new Element(id));return map.get(id);};const ctx={setTransform(){},clearRect(){draws.length=0;},beginPath(){this.p=[];},moveTo(x,y){this.p.push([x,y]);},lineTo(x,y){this.p.push([x,y]);},quadraticCurveTo(cx,cy,x,y){this.p.push([x,y]);},closePath(){},fill(){draws.push({kind:'fill',colour:this.fillStyle,points:this.p});},stroke(){draws.push({kind:'stroke',colour:this.strokeStyle,points:this.p});},fillText(s,x,y){draws.push({kind:'text',s,x,y});},save(){},restore(){},translate(){},rotate(){},setLineDash(){}};const canvas=get('board');canvas.clientWidth=width;canvas.clientHeight=height;canvas.getContext=()=>ctx;get('screen-play').className='screen hidden';get('screen-select').className='screen';const window={devicePixelRatio:1,handlers:{},addEventListener(k,f){this.handlers[k]=f;}};if(withAudio){let clock=0;window.AudioContext=class{get currentTime(){return clock+=.25;}constructor(){this.state='running';this.destination={};}createOscillator(){return {frequency:{setValueAtTime(){}},connect(){},disconnect(){},start(){},stop(){}};}createGain(){return {gain:{setValueAtTime(){},linearRampToValueAtTime(){},exponentialRampToValueAtTime(){}},connect(){},disconnect(){}};}};}const html=fs.readFileSync(new URL('../index.html',import.meta.url),'utf8');const script=html.match(/<script>([\s\S]*)<\/script>/)[1];const context=vm.createContext({document:{getElementById:get,createElement:()=>new Element()},window,localStorage:{getItem:k=>storage.get(k)||null,setItem:(k,v)=>storage.set(k,v)},performance:{now:()=>1000},console,Blob,URL:{createObjectURL:()=> 'blob:test',revokeObjectURL(){}},setTimeout:f=>f(),clearTimeout(){}});new vm.Script(script).runInContext(context);
const snapshot=()=>JSON.parse(JSON.stringify(window.variantSnapshot()));const press=id=>{const [x,y]=snapshot().centers[id];canvas.handlers.pointerdown({preventDefault(){},pointerId:1,clientX:x,clientY:y});canvas.handlers.pointerup({});};const select=n=>{const id=pack.levels[n-1].id;for(const section of get('pack-list').children){const b=section.children[1].children.find(b=>b.levelId===id);if(b){b.click();return;}}throw Error('Missing level '+id);};return {get,draws,canvas,window,snapshot,press,select,storage,downloads};}
test('all 200 actual embedded levels select, solve, undo, restart and hint',()=>{const ui=boot();assert.equal(ui.get('pack-list').children.length,20);for(let n=1;n<=200;n++){ui.select(n);const l=pack.levels[n-1],p=solve(l)[0];assert.equal(ui.snapshot().displayOrder,n);assert.equal(ui.get('pack-list').children[Math.floor((n-1)/10)].children[1].children.length,10);ui.press(p[0]);ui.press(p[1]);ui.press(p[0]);assert.deepEqual(ui.snapshot().path,p.slice(0,1));ui.get('hint').click();ui.get('restart').click();for(const id of p)ui.press(id);assert.equal(ui.snapshot().cleared,true,'level '+n);ui.get('undo').click();assert.equal(ui.snapshot().cleared,false);ui.press(p.at(-1));assert.equal(ui.snapshot().cleared,true);assert.equal(ui.get('next').hidden,n===200);}});
test('tutorials show before every first appearance and transition into practice',()=>{const ui=boot();for(const l of pack.levels.filter(l=>l.tutorial)){ui.select(l.order);assert.equal(ui.snapshot().tutorial,l.tutorial);assert.ok(ui.get('caption').textContent.length>8);for(const id of solve(l)[0])ui.press(id);assert.equal(ui.snapshot().cleared,true);ui.get('next').click();assert.equal(ui.snapshot().order,l.order+1);}});
test('narrow portrait and short landscape support all 200 levels',()=>{for(const [w,h]of [[296,290],[480,260]]){const ui=boot(w,new Map(),h);for(const l of pack.levels){ui.select(l.order);for(const [x,y]of Object.values(ui.snapshot().centers)){assert.ok(x>=0&&x<=w);assert.ok(y>=0&&y<=h);}for(const id of solve(l)[0])ui.press(id);assert.equal(ui.snapshot().cleared,true);}}});
test('independent progress, mute and current-level storage',()=>{const memory=new Map(),ui=boot(340,memory);ui.select(31);ui.get('btn-sound').click();for(const id of solve(pack.levels[30])[0])ui.press(id);const reopened=boot(340,memory);assert.equal(reopened.snapshot().order,31);assert.equal(reopened.snapshot().soundEnabled,false);assert.equal(memory.has('variant:campaign-50:v1'),false);assert.match(reopened.get('progress').textContent,/1 \/ 200/);});
test('movement-only undo ignores older visited cells and keeps icon location',()=>{const ui=boot();const l=pack.levels.find(l=>!l.tutorial&&l.cells.some(c=>c.direction));ui.select(l.order);const p=solve(l)[0],before=ui.snapshot().directionMarkers;for(const id of p.slice(0,5))ui.press(id);ui.press(p[0]);assert.deepEqual(ui.snapshot().path,p.slice(0,5));ui.press(p[3]);assert.deepEqual(ui.snapshot().path,p.slice(0,4));assert.deepEqual(ui.snapshot().directionMarkers.map(m=>m.center),before.map(m=>m.center));});