import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { CAMPAIGN, LEVELS } from '../src/levels.js'; import { Game, available, trace, random, validateLevel, opposite, key } from '../src/core.js'; import { generateFlowLevel, inspectFlowLevel } from '../src/campaign-generator.js'; const reviewed=LEVELS.map(inspectFlowLevel); test('48 authored levels share one campaign in the browser, JSON export and audit report',async()=>{ assert.equal(LEVELS.length,48);assert.equal(CAMPAIGN.chapterSize,6);assert.equal(CAMPAIGN.chapters.length,8); assert.equal(new Set(LEVELS.map(l=>l.id)).size,48); assert.deepEqual(JSON.parse(await readFile(new URL('../levels/campaign.json',import.meta.url),'utf8')),LEVELS); const report=JSON.parse(await readFile(new URL('../levels/campaign-analysis.json',import.meta.url),'utf8')); const manifest=JSON.parse(await readFile(new URL('../levels/campaign-seeds.json',import.meta.url),'utf8')); assert.deepEqual(report.campaign,CAMPAIGN);assert.equal(report.levels.length,48); assert.equal(manifest.seeds.length,48);assert.equal(new Set(manifest.seeds.map(p=>p.seed)).size,48); LEVELS.forEach((l,i)=>{ const profile=manifest.seeds[i],generated=generateFlowLevel(profile.seed,profile.options); assert.equal(profile.level,i+1);assert.deepEqual(generated.cells,l.cells);assert.deepEqual(generated.tiles,l.tiles); assert.deepEqual(validateLevel(l),[],l.name); assert.equal(l.chapter,Math.floor(i/6)+1);assert.equal(l.chapterName,CAMPAIGN.chapters[Math.floor(i/6)]); assert.equal(l.moveLimit,l.tiles.length); assert.equal(l.pacing.cognitiveDifficulty,reviewed[i].analysis.score); assert.equal(report.levels[i].difficulty,reviewed[i].analysis.score); assert.equal(report.levels[i].tiles,l.tiles.length); assert.deepEqual(report.levels[i].pathUsage,reviewed[i].analysis.pathUsage); }); }); test('every campaign solution finishes, avoids hidden blockers, and needs at most one turn between removals',()=>{ reviewed.forEach((q,i)=>{ const label=LEVELS[i].name; assert.ok(q.analysis.solvable&&!q.analysis.exhausted&&q.won,label); assert.equal(q.independentLoops,0,`${label}: solitary piece must always have an exit in all six orientations`); assert.equal(q.hiddenBlockerStates,0,`${label}: blocked routes must identify a piece on that route`); assert.ok(q.maxDryRotations<=1&&q.maxUnlockDistance<=1,label); assert.ok(q.analysis.free>=2,`${label}: at least two immediate opening choices`); }); }); test('first level teaches one visible bend per piece without forced rotation or removal order',()=>{ const l=LEVELS[0];assert.equal(l.cells.length,37);assert.equal(l.tiles.length,8); for(const reverse of [false,true]){ const g=new Game(l),order=g.tiles.map(t=>t.id); if(reverse)order.reverse(); for(const id of order){ assert.equal(available(g.level,g.tiles).length,g.tiles.length,'No mysterious final-piece lock or forced removal order'); assert.equal(g.moves,g.tiles.length); const route=g.tap(id);assert.equal(route.status,'exit'); assert.equal(route.steps.filter(s=>s.enter!==null&&opposite(s.enter)!==s.exit).length,1,'The moving arrow must actually turn'); } assert.ok(g.won);assert.equal(g.moves,0); } }); test('all 48 levels use actual bent removal paths, including the release and large-board levels',()=>{ for(let i=0;i=.65,`${LEVELS[i].name}: straight exits must remain a minority`); assert.ok(p.curvedRemovals>=3); assert.equal(LEVELS[i].pacing.curvedRatio,p.curvedRatio); } }); test('the first seven lessons include real turning, crossing, and route-changing situations',()=>{ assert.ok(LEVELS.slice(0,7).every(l=>typeof l.lesson==='string'&&l.lesson.length)); assert.equal(reviewed[0].analysis.pathUsage.singleBendRemovals,8); assert.equal(reviewed[1].analysis.rotations,1); assert.ok(reviewed[2].analysis.pathUsage.multiBendRemovals>0); const g=new Game(LEVELS[2]);let crossings=0; for(const action of reviewed[2].analysis.actions){ if(action.type==='rotate'){g.rotate(action.direction);continue;} for(const step of g.tap(action.id).steps){ if(step.enter===null)continue; const cell=g.level.cells.find(c=>key(c.q,c.r)===key(step.q,step.r)); const inside=p=>p!==step.enter&&(p-step.enter+6)%6<(step.exit-step.enter+6)%6; for(let p=0;p<6;p++)if(p0,'The crossing lesson must pass through interleaved track pairs'); }); test('exploring orientations and alternative safe removal orders cannot soft-lock the campaign',()=>{ for(const l of LEVELS)for(let run=0;run<4;run++){ const rng=random(l.seed+run*997),g=new Game(l); while(g.tiles.length){ const budget=g.moves; for(let i=0,n=Math.floor(rng()*6);itrace(g.level,t,g.tiles).status!=='exit'); if(blocked){g.tap(blocked.id);assert.equal(g.moves,budget);assert.equal(g.lost,false);} let choices=available(g.level,g.tiles); for(let turns=0;!choices.length&&turns<5;turns++){g.rotate(1);choices=available(g.level,g.tiles);} assert.ok(choices.length,`${l.name}: exploration must retain a reachable exit`); assert.equal(g.tap(choices[Math.floor(rng()*choices.length)].id).status,'exit'); assert.equal(g.moves,g.tiles.length); } assert.ok(g.won,l.name); } }); test('each chapter releases cognitive pressure after its small challenge while the larger finale lasts longer',()=>{ for(let chapter=0;chapter<8;chapter++){ const block=LEVELS.slice(chapter*6,chapter*6+6),q=reviewed.slice(chapter*6,chapter*6+6).map(r=>r.analysis); assert.deepEqual(block.map(l=>l.beat),['进入','换路','展开','小挑战','释放','长局回报']); assert.ok(q[1].rotations>0&&q[3].rotations>0,'small boards retain meaningful route changes'); assert.ok(q[4].score<=q[3].score-12&&q[5].score<=q[3].score-10,'challenge is followed by a clear release'); assert.ok(block[5].cells.length>block[3].cells.length&&block[5].tiles.length>block[3].tiles.length); assert.ok(q[5].workload.estimatedSeconds>q[3].workload.estimatedSeconds,'length is a separate structural estimate'); assert.equal(q[5].rotations,0,'large finales allow a continuous sequence of removals'); } });