Files
HexTrails/tests/campaign-versions.test.js
T
cabinwandClaude Opus 5 7607f48cb1 feat(levels): boards whose interior is packed and whose rim is left empty
The game has never had a board you simply clear out, and the request for one
turned out to need a construction of its own rather than a denser deep lock.
Filling cells at random can never work: a packed board has nothing that can
move — measured over forty random fills of a 37-cell board, zero pieces free in
all six orientations, every time, because a piece steps to the next cell before
it can leave and on a full board that cell is taken.

So packBoard builds backwards. Start empty, add only a piece that could leave
right now, and the placement order reversed is a removal order — solvability
becomes a property of the construction. Which cell to fill next decides
everything: at random it stalls near half, while filling the most constrained
cell first takes a 19-cell interior to 96% and fills it outright.

The rim is deliberately empty. Those pieces leave first and ask nothing, and
clearing from the rim inward gives a packed board its own difficulty curve
inside one level — the outer cells read easily, and it tightens as it opens.

Two things were tried against the ceiling and failed, and are recorded as such:
depth-first backtracking made it worse on both counts (65-78% filled against
greedy's 89%, at 2000-3000 seconds a board) because recomputing every cell's
legal directions per node spends the budget three levels deep. And the audit's
dead-turn gate could not be trusted here at all — on a packed board its breadth
search truncates, so it certified levels the rule bot then walked through three
blind turns in a row. The pool is filtered by playing it instead: a full pass
takes one second and does not lie.

The campaign is 32 packed levels to 18 deep locks, alternating, with the closing
six at a completely full interior. Same rule bot, both editions: worst blind
streak 1 either way, dead taps zero, and half again as much to do per level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:23:18 +08:00

138 lines
7.4 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],['c50b',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');
});
// A campaign edition is registered by hand, and app.js spreads several of its
// fields on load. Leaving one out registers an edition that looks fine in every
// other test and blanks the board the moment somebody opens it — which is how
// c50 shipped its first build.
test('every campaign edition carries the fields the app reads on load', () => {
for (const edition of CAMPAIGN_VERSIONS) {
assert.ok(edition.id && edition.name, `${edition.id}: 缺 id/name`);
assert.ok(Array.isArray(edition.levels) && edition.levels.length, `${edition.id}: 缺关卡`);
assert.ok(Array.isArray(edition.chapters) && edition.chapters.length, `${edition.id}: 缺章节`);
assert.ok(Number.isFinite(edition.chapterSize) && edition.chapterSize > 0, `${edition.id}: chapterSize 非法`);
// app.js iterates this one directly, so a missing array is a TypeError.
assert.ok(Array.isArray(edition.legacyStorageKeys), `${edition.id}: legacyStorageKeys 必须是数组`);
assert.ok(edition.chapters.length >= Math.ceil(edition.levels.length / edition.chapterSize),
`${edition.id}: 章节数不够放下 ${edition.levels.length} 关`);
}
});