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); } });