Restore v0.1 and v0.2 archives with separate progress, publish the curved v0.3 campaign, and document the original 24-level design with reproducible evidence. Refine board presentation and movement, and keep video lab tooling within its own directory.
115 lines
6.5 KiB
JavaScript
115 lines
6.5 KiB
JavaScript
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<LEVELS.length;i++){
|
|
const p=reviewed[i].analysis.pathUsage;
|
|
assert.equal(p.removals,LEVELS[i].tiles.length);
|
|
assert.ok(p.curvedRatio>=.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(p<cell.tracks[p]&&p!==step.enter&&p!==step.exit&&inside(p)!==inside(cell.tracks[p]))crossings++;
|
|
}
|
|
}
|
|
assert.ok(crossings>0,'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);i<n;i++)assert.equal(g.rotate(rng()<.5?-1:1).status,'rotated');
|
|
assert.equal(g.moves,budget);
|
|
const blocked=g.tiles.find(t=>trace(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');
|
|
}
|
|
});
|