The deep-lock construction cannot go below three turns, so it cannot supply an opening band — every level it makes lands mid-difficulty and the curve comes out flat. harvest-easy pulls the shallow end from the shipped v0.5 set, but only the 3% of it that holds up: 568 of 1000 need no rotation at all and 51 more are clearable by tapping at random, which is what the monkey threshold is for. 33 levels survive, at one and two turns. select-50 scores candidates on mechanical features only, spreads the pick evenly across that range, and caps any one route grammar at 36% so the campaign cannot quietly become one template repeated — the shipped 1000 turned out to be thirty. Weights are a flag, so refitting them from a round of agent ratings is a re-run rather than an edit. The result is a curve rather than a plateau: difficulty score 3.85 / 6.24 / 7.60 across the three bands, rotations 1.8 / 3.0 / 3.5, and every level keeps three or more opening choices with a show of at least a second. The 50 register as a playable campaign edition, so review happens in the real game; docs/review-50.html is the handover sheet, one card per level with the opening position drawn from the solver rather than sketched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
6.3 KiB
JavaScript
121 lines
6.3 KiB
JavaScript
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('every selectable edition retains its own level count, order and chapters',()=>{
|
|
// Historical editions keep their exact shape; new ones are appended, because
|
|
// other tests address the originals by position.
|
|
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],['v0.4',20,5,4],['v0.5',1000,50,20],['c50',50,10,5],
|
|
]);
|
|
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 120 earlier layouts solve and replay with the current free rotation rules',()=>{
|
|
for(const c of CAMPAIGN_VERSIONS.slice(0,3))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.5 while an explicit version choice survives reload',()=>{
|
|
const s=storage();assert.equal(selectedCampaign(s).id,'v0.5');
|
|
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.5');
|
|
});
|
|
|
|
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.slice(0,3).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.5');
|
|
assert.deepEqual(readCampaignProgress(s,campaignVersion('v0.1')),{current:0,completed:[]});
|
|
assert.equal(writeCampaignProgress(s,campaignVersion('v0.1'),{current:1,completed:[0]}),false);
|
|
}
|
|
});
|
|
|
|
|
|
test('the production campaign starts independently and retains its thousandth level',()=>{
|
|
const old=JSON.stringify({current:19,completed:[0,19]});
|
|
const s=storage({[CAMPAIGN_SELECTION_KEY]:'v0.4',[campaignProgressKey('v0.4')]:old});
|
|
const production=campaignVersion('v0.5');
|
|
assert.equal(selectedCampaign(s).id,'v0.4');
|
|
assert.deepEqual(readCampaignProgress(s,production),{current:0,completed:[]});
|
|
writeCampaignProgress(s,production,{current:999,completed:[0,998,999]});
|
|
assert.deepEqual(readCampaignProgress(s,production),{current:999,completed:[0,998,999]});
|
|
assert.equal(s.getItem(campaignProgressKey('v0.4')),old);
|
|
assert.equal(selectedCampaign(s).id,'v0.5');
|
|
});
|