feat: version campaigns and document original puzzle design
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.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Game, solveRotations, validateLevel } from '../src/core.js';
|
||||
import { LEVELS } from '../src/levels.js';
|
||||
import { CAMPAIGN_VERSIONS, campaignVersion } from '../src/campaign-versions.js';
|
||||
import { CAMPAIGN_SELECTION_KEY, campaignProgressKey, selectedCampaign, normalizeProgress, readCampaignProgress, writeCampaignProgress } from '../src/campaign-progress.js';
|
||||
|
||||
const storage=entries=>{
|
||||
const items=new Map(Object.entries(entries||{}));
|
||||
return {getItem:key=>items.get(key)??null,setItem:(key,value)=>items.set(key,value)};
|
||||
};
|
||||
|
||||
test('three selectable editions retain their own level counts, order and chapters',()=>{
|
||||
assert.deepEqual(CAMPAIGN_VERSIONS.map(c=>[c.id,c.levels.length,c.chapterSize,c.chapters.length]),[
|
||||
['v0.1',24,8,3],['v0.2',48,6,8],['v0.3',48,6,8],
|
||||
]);
|
||||
assert.equal(campaignVersion('v0.1').levels[0].name,'转一转,路就通了');
|
||||
assert.equal(campaignVersion('v0.1').levels.at(-1).name,'六角大满贯');
|
||||
assert.equal(campaignVersion('v0.2').levels[0].name,'一眼看见出口');
|
||||
assert.equal(campaignVersion('v0.3').levels,LEVELS);
|
||||
assert.equal(campaignVersion('v0.3').levels[0].name,'跟着弯道走');
|
||||
for(const c of CAMPAIGN_VERSIONS)assert.equal(c.chapterSize*c.chapters.length,c.levels.length);
|
||||
});
|
||||
|
||||
test('browser archive module matches the exact historical JSON bytes and provenance',async()=>{
|
||||
const manifest=JSON.parse(await readFile(new URL('../levels/versions/provenance.json',import.meta.url),'utf8'));
|
||||
for(const source of manifest.versions){
|
||||
const bytes=await readFile(new URL('../'+source.archivePath,import.meta.url));
|
||||
assert.equal(bytes.length,source.bytes);
|
||||
assert.equal(createHash('sha256').update(bytes).digest('hex'),source.sha256);
|
||||
assert.deepEqual(campaignVersion(source.version).levels,JSON.parse(bytes.toString('utf8')));
|
||||
}
|
||||
});
|
||||
|
||||
test('all 72 historical layouts solve and replay with the current free rotation rules',()=>{
|
||||
for(const c of CAMPAIGN_VERSIONS.slice(0,2))for(const [i,level] of c.levels.entries()){
|
||||
const label=`${c.id} level ${i+1}`;
|
||||
assert.deepEqual(validateLevel(level),[],label);
|
||||
const plan=solveRotations(level),game=new Game(level);
|
||||
assert.ok(plan.solvable&&!plan.exhausted,label);
|
||||
for(const action of plan.actions){
|
||||
const before=game.moves;
|
||||
if(action.type==='rotate'){
|
||||
assert.equal(game.rotate(action.direction).status,'rotated',label);
|
||||
assert.equal(game.moves,before,label);
|
||||
}else assert.equal(game.tap(action.id).status,'exit',label);
|
||||
}
|
||||
assert.ok(game.won,label);
|
||||
}
|
||||
});
|
||||
|
||||
test('new players default to v0.3 while an explicit version choice survives reload',()=>{
|
||||
const s=storage();assert.equal(selectedCampaign(s).id,'v0.3');
|
||||
assert.ok(writeCampaignProgress(s,campaignVersion('v0.1'),{current:23,completed:[0,22]}));
|
||||
assert.equal(selectedCampaign(s).id,'v0.1');
|
||||
assert.deepEqual(readCampaignProgress(s,selectedCampaign(s)),{current:23,completed:[0,22]});
|
||||
s.setItem(CAMPAIGN_SELECTION_KEY,'v9.9');assert.equal(selectedCampaign(s).id,'v0.3');
|
||||
});
|
||||
|
||||
test('versions keep progress separate even when v0.2 and v0.3 reuse level IDs',()=>{
|
||||
const s=storage(),v2=campaignVersion('v0.2'),v3=campaignVersion('v0.3');
|
||||
assert.equal(v2.levels[0].id,v3.levels[0].id);
|
||||
writeCampaignProgress(s,v2,{current:47,completed:[0,1,47]});
|
||||
assert.deepEqual(readCampaignProgress(s,v3),{current:0,completed:[]});
|
||||
writeCampaignProgress(s,v3,{current:7,completed:[6]});
|
||||
assert.deepEqual(readCampaignProgress(s,v2),{current:47,completed:[0,1,47]});
|
||||
assert.deepEqual(readCampaignProgress(s,v3),{current:7,completed:[6]});
|
||||
assert.deepEqual(readCampaignProgress(s,campaignVersion('v0.1')),{current:0,completed:[]});
|
||||
});
|
||||
|
||||
test('each edition migrates only its own legacy slot without changing the old data',()=>{
|
||||
const old={
|
||||
'hex-trails-v2':JSON.stringify({current:20,completed:[0,20]}),
|
||||
'hex-trails-observe-v3':JSON.stringify({current:45,completed:[0,45]}),
|
||||
'hex-trails-observe-v3-r2':JSON.stringify({current:6,completed:[6]}),
|
||||
};
|
||||
const s=storage(old);
|
||||
for(const [i,c] of CAMPAIGN_VERSIONS.entries()){
|
||||
const key=Object.keys(old)[i],expected=JSON.parse(old[key]);
|
||||
assert.deepEqual(readCampaignProgress(s,c),expected);
|
||||
writeCampaignProgress(s,c,{current:1,completed:[1]});
|
||||
assert.deepEqual(readCampaignProgress(s,c),{current:1,completed:[1]});
|
||||
assert.equal(s.getItem(key),old[key]);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid saves are bounded to each edition and do not resurrect legacy progress',()=>{
|
||||
assert.deepEqual(normalizeProgress({current:47,completed:[-1,0,0,23,24,47,1.5,'2',null]},24),{current:0,completed:[0,23]});
|
||||
const c=campaignVersion('v0.1');
|
||||
for(const raw of ['{','null','[]','true','"text"']){
|
||||
const s=storage({[campaignProgressKey(c.id)]:raw,'hex-trails-v2':JSON.stringify({current:20,completed:[20]})});
|
||||
assert.deepEqual(readCampaignProgress(s,c),{current:0,completed:[]});
|
||||
}
|
||||
});
|
||||
|
||||
test('unavailable browser storage still permits loading a campaign',()=>{
|
||||
const denied={getItem(){throw new Error('denied');},setItem(){throw new Error('denied');}};
|
||||
for(const s of [undefined,denied]){
|
||||
assert.equal(selectedCampaign(s).id,'v0.3');
|
||||
assert.deepEqual(readCampaignProgress(s,campaignVersion('v0.1')),{current:0,completed:[]});
|
||||
assert.equal(writeCampaignProgress(s,campaignVersion('v0.1'),{current:1,completed:[0]}),false);
|
||||
}
|
||||
});
|
||||
+42
-8
@@ -2,8 +2,8 @@ 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';
|
||||
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);
|
||||
|
||||
@@ -12,14 +12,19 @@ test('48 authored levels share one campaign in the browser, JSON export and audi
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,20 +39,49 @@ test('every campaign solution finishes, avoids hidden blockers, and needs at mos
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
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){
|
||||
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.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);
|
||||
|
||||
@@ -16,6 +16,12 @@ test('bends follow the paired exit, never a crossing branch',()=>{
|
||||
const l=level([{q:0,r:0},{q:0,r:-1,tracks:[2,3,0,1,5,4]},{q:1,r:-2}],[tile('a',0,0)]);
|
||||
const path=trace(l,l.tiles[0]);assert.equal(path.status,'exit');assert.equal(path.steps[1].enter,3);assert.equal(path.steps[1].exit,1);assert.deepEqual(path.steps[2],{q:1,r:-2,enter:4,exit:1});
|
||||
});
|
||||
test('path usage counts successful bends and ignores decorative tracks outside the traveled route',()=>{
|
||||
const bent=level([{q:0,r:0},{q:0,r:-1,tracks:[2,3,0,1,5,4]},{q:1,r:-2}],[tile('a',0,0)]);
|
||||
const p=analyze(bent).pathUsage;assert.equal(p.curvedRemovals,1);assert.equal(p.singleBendRemovals,1);assert.equal(p.totalBends,1);assert.equal(p.curvedRatio,1);
|
||||
const decoration=level([{q:0,r:0},{q:1,r:0,tracks:[1,0,3,2,5,4]}],[tile('a',0,0)]);
|
||||
const unused=analyze(decoration).pathUsage;assert.equal(unused.curvedRemovals,0);assert.equal(unused.straightRemovals,1);assert.equal(unused.curvedRatio,0);
|
||||
});
|
||||
test('occupied cell blocks even when its own arrow points elsewhere',()=>{
|
||||
const l=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1,1)]);
|
||||
assert.equal(trace(l,l.tiles[0]).blocker,'b');assert.deepEqual(available(l).map(t=>t.id),['b']);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Game, solve, validateLevel } from '../src/core.js';
|
||||
import { makeLabLevel } from '../labs/hex-video/fixture.js';
|
||||
|
||||
test('video lab fixture validates and can complete through real game actions', () => {
|
||||
const level = makeLabLevel();
|
||||
assert.deepEqual(validateLevel(level), []);
|
||||
assert.equal(level.tiles.length, 18);
|
||||
const solution = solve(level), game = new Game(level);
|
||||
assert.equal(solution.solvable, true);
|
||||
const blocked = game.tap('lab-0');
|
||||
assert.equal(blocked.status, 'blocked');
|
||||
assert.equal(game.moves, level.moveLimit);
|
||||
for (const id of solution.order) assert.equal(game.tap(id).status, 'exit');
|
||||
assert.equal(game.won, true);
|
||||
assert.equal(game.moves, 0);
|
||||
assert.equal(game.undo(), true);
|
||||
assert.equal(game.won, false);
|
||||
assert.equal(game.tiles.length, 1);
|
||||
});
|
||||
+25
-12
@@ -35,8 +35,8 @@ test('edge exits are brisk and longer routes preserve a bounded readable travel
|
||||
const edge={status:'exit',steps:[{q:0,r:0,enter:null,exit:0}],end:{q:0,r:-1}};
|
||||
const short=exitTiming(edge);
|
||||
const long=exitTiming({...edge,steps:[edge.steps[0],{q:0,r:-1,enter:3,exit:0},{q:0,r:-2,enter:3,exit:0}]});
|
||||
assert.equal(short.duration,320);
|
||||
assert.ok(long.duration>short.duration&&long.duration<=980);
|
||||
assert.ok(short.duration>260&&short.duration<400);
|
||||
assert.ok(long.duration>short.duration&&long.duration<1200);
|
||||
for(const timing of [short,long])assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
|
||||
});
|
||||
|
||||
@@ -52,15 +52,28 @@ test('motion ramps gently at both ends, never reverses or overshoots',async()=>{
|
||||
});
|
||||
|
||||
|
||||
test('long routes regain original pacing while edge exits and the finish stay brisk',async()=>{
|
||||
const {exitTiming}=await import('../src/exit-animation.js');
|
||||
const route=n=>({status:'exit',steps:Array.from({length:n},(_,i)=>({q:0,r:-i,enter:i?3:null,exit:0}))});
|
||||
assert.equal(exitTiming(route(1)).duration,320);
|
||||
for(const [cells,expected] of [[5,975],[10,1400],[17,1700]]){
|
||||
const timing=exitTiming(route(cells));
|
||||
assert.equal(timing.duration,expected);
|
||||
assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
|
||||
test('curved and long routes share the same cruising speed without a duration cap',async()=>{
|
||||
const {exitTiming,travelProgress}=await import('../src/exit-animation.js');
|
||||
for(const distance of [1,5,15,30]){
|
||||
const timing=exitTiming({travelPoints:[{x:0,y:0},{x:distance,y:0}],steps:[]});
|
||||
assert.equal(timing.travelMs,distance*120+Math.min(80,distance*40));
|
||||
const t=timing.travelMs*.5,dt=1;
|
||||
const speed=(travelProgress((t+dt)/timing.travelMs,timing.travelMs)-travelProgress(t/timing.travelMs,timing.travelMs))*distance;
|
||||
assert.ok(Math.abs(speed-1/120)<1e-9);
|
||||
assert.equal(travelProgress(0,timing.travelMs),0);assert.equal(travelProgress(1,timing.travelMs),1);
|
||||
assert.ok(Math.abs(timing.duration-timing.travelMs-180)<1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
test('showcase boards open with a long curved route and can be cleared completely',async()=>{
|
||||
const {createStudyLevel}=await import('../src/study-levels.js');
|
||||
const {available,trace,Game,validateLevel}=await import('../src/core.js');
|
||||
for(const [radius,minLength] of [[3,5],[5,8],[6,11]]){
|
||||
const level=createStudyLevel(radius),game=new Game(level);
|
||||
assert.deepEqual(validateLevel(level),[]);
|
||||
const routes=available(level,level.tiles).map(t=>trace(level,t,level.tiles));
|
||||
assert.ok(routes.some(r=>r.steps.length>=minLength&&r.steps.some(s=>s.enter!==null&&(s.enter+3)%6!==s.exit)));
|
||||
while(game.tiles.length){const tile=available(game.level,game.tiles)[0];assert.ok(tile);game.tap(tile.id);}
|
||||
assert.ok(game.won);
|
||||
}
|
||||
const durations=Array.from({length:17},(_,i)=>exitTiming(route(i+1)).duration);
|
||||
assert.ok(durations.every((t,i)=>i===0||t>=durations[i-1]));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user