fix(campaign): register c50 with the fields the app actually reads

Opening the c50 edition threw `campaign.legacyStorageKeys is not iterable`
and left the board blank — the header still drew, so the level set looked
loaded. Every other edition carries the array; the hand-written c50 entry
did not, and 197 passing tests had nothing to say about it, because nothing
tested the registration itself.

The test now walks every edition and asserts the shape app.js relies on,
including that the chapter list can actually hold the levels. It fails on the
entry as it shipped.

The task board was reading levels/prototypes/d5.json while the campaign sat in
levels/campaign-50.json, so it showed "候选关卡 18 / 50" with fifty levels
built. It reads the shipping set when there is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 17:03:37 +08:00
co-authored by Claude Opus 5
parent 96e5e4c21f
commit b35ea201db
3 changed files with 23 additions and 2 deletions
+5 -2
View File
@@ -20,7 +20,10 @@ const f2 = x => x == null || Number.isNaN(x) ? '—' : (Math.round(x * 100) / 10
const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null; const mean = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
// ---------- live measurement ---------- // ---------- live measurement ----------
const SET = resolve('levels/prototypes/d5.json'); // The shipping set once it exists, the prototype output before that. Reading
// the prototype after the campaign was assembled showed the tile as "18 / 50"
// while fifty levels were sitting in levels/campaign-50.json.
const SET = [resolve('levels/campaign-50.json'), resolve('levels/prototypes/d5.json')].find(existsSync);
let pool = { n: 0, rot: {}, apt: null, families: {}, blind: null, wrong: null, endgame: {} }; let pool = { n: 0, rot: {}, apt: null, families: {}, blind: null, wrong: null, endgame: {} };
if (existsSync(SET)) { if (existsSync(SET)) {
const raw = JSON.parse(readFileSync(SET, 'utf8')); const raw = JSON.parse(readFileSync(SET, 'utf8'));
@@ -29,7 +32,7 @@ if (existsSync(SET)) {
for (const l of levels) { for (const l of levels) {
const r = l.prototype?.stats?.rotations ?? solveRotations(l).rotations; const r = l.prototype?.stats?.rotations ?? solveRotations(l).rotations;
pool.rot[r] = (pool.rot[r] || 0) + 1; pool.rot[r] = (pool.rot[r] || 0) + 1;
const fam = l.id.replace('proto-d5-', '').replace(/-r\d+-s\d+$/, ''); const fam = l.campaign?.family ?? l.id.replace('proto-d5-', '').replace(/-r\d+-s\d+$/, '');
pool.families[fam] = (pool.families[fam] || 0) + 1; pool.families[fam] = (pool.families[fam] || 0) + 1;
const a = l.prototype?.audit ?? deadTurnAudit(l); const a = l.prototype?.audit ?? deadTurnAudit(l);
pool.endgame[a.endgameMinOpen] = (pool.endgame[a.endgameMinOpen] || 0) + 1; pool.endgame[a.endgameMinOpen] = (pool.endgame[a.endgameMinOpen] || 0) + 1;
+1
View File
@@ -36,6 +36,7 @@ export const CAMPAIGN_VERSIONS = [
{ {
id:C50_CAMPAIGN.id, name:C50_CAMPAIGN.name, description:C50_CAMPAIGN.description, id:C50_CAMPAIGN.id, name:C50_CAMPAIGN.name, description:C50_CAMPAIGN.description,
chapters:C50_CAMPAIGN.chapters, chapterSize:C50_CAMPAIGN.chapterSize, levels:C50_LEVELS, chapters:C50_CAMPAIGN.chapters, chapterSize:C50_CAMPAIGN.chapterSize, levels:C50_LEVELS,
legacyStorageKeys:[],
}, },
]; ];
export const DEFAULT_CAMPAIGN_VERSION = 'v0.5'; export const DEFAULT_CAMPAIGN_VERSION = 'v0.5';
+17
View File
@@ -118,3 +118,20 @@ test('the production campaign starts independently and retains its thousandth le
assert.equal(s.getItem(campaignProgressKey('v0.4')),old); assert.equal(s.getItem(campaignProgressKey('v0.4')),old);
assert.equal(selectedCampaign(s).id,'v0.5'); 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} 关`);
}
});