81 lines
4.6 KiB
JavaScript
81 lines
4.6 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, rotateBoard, trace, random, validateLevel } from '../src/core.js';
|
|
import { 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'));
|
|
assert.deepEqual(report.campaign,CAMPAIGN);assert.equal(report.levels.length,48);
|
|
LEVELS.forEach((l,i)=>{
|
|
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);
|
|
});
|
|
});
|
|
|
|
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 is spacious and every remaining tile can leave after arbitrary free rotations',()=>{
|
|
const l=LEVELS[0];assert.ok(l.cells.length>=37);assert.ok(l.tiles.length>=12);
|
|
for(let orientation=0;orientation<6;orientation++)for(const reverse of [false,true]){
|
|
const g=new Game(rotateBoard(l,orientation)),order=g.tiles.map(t=>t.id);
|
|
if(reverse)order.reverse();
|
|
for(const id of order){
|
|
for(let n=0;n<5;n++)assert.equal(g.rotate(reverse?-1:1).status,'rotated');
|
|
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);assert.equal(g.tap(id).status,'exit');
|
|
}
|
|
assert.ok(g.won);assert.equal(g.moves,0);
|
|
}
|
|
});
|
|
|
|
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');
|
|
}
|
|
});
|