feat(levels): c50c trades one good idea repeated for five that alternate

c50b is sealed where it stands, as a reference sample rather than a candidate.
Its packed construction was judged good — clearing a board from the rim inward
is the experience it proved out — but at 32 of 50 levels it played as one level
fifty times: 体验太单一了,因为基本都是满盘. The note on why it is kept lives at
the top of its module, because the lesson is the useful part.

It was monotonous on a second axis nobody asked about. 环绕 and 花瓣, the two
route families the brief singles out as the good-looking ones, were absent from
it entirely — not by choice but by a crossing threshold. At 1.0 crossings per
cell, 0% of orbits boards pass and 4% of spirals; at 1.2, 84% and 94% do. The
gate meant to answer "交叉点太多" had quietly deleted the families most worth
looking at, so it moves to 1.2 — still half the 2.36 that drew the complaint.

c50c alternates five kinds of level every three to five: packed boards down to
32%, long-route departures to watch, deep locks to solve, small quick boards,
and segments given over to orbits, petals and spirals by name. All six families
appear, no family exceeds 24%, and the plan is 13 segments rather than 9.

Selection learned to ask for variety outright — by family, piece count and
longest drawn route — instead of hoping it falls out of a difficulty score.

Rule bot across all three: 50/50 cleared and zero dead taps everywhere; c50c
sits between the other two on blind rotations and on how much there is to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-15 15:53:26 +08:00
co-authored by Claude Opus 5
parent e447d1f807
commit 6e72b95ad8
9 changed files with 824965 additions and 16 deletions
+129150 -3
View File
File diff suppressed because it is too large Load Diff
+625025
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+91 -9
View File
@@ -1,11 +1,93 @@
[
{ "name": "起步 · 小满盘", "count": 6, "packed": true, "maxCells": 37 },
{ "name": "深锁初见", "count": 4, "rotations": [3], "packed": false, "maxDensity": 0.28 },
{ "name": "满盘 · 由外向内", "count": 7, "packed": true },
{ "name": "变奏 · 深一点", "count": 5, "rotations": [4, 5], "packed": false },
{ "name": "大满盘", "count": 7, "packed": true, "minCells": 61 },
{ "name": "中段 · 稀而绕", "count": 4, "rotations": [3], "packed": false, "maxDensity": 0.30 },
{ "name": "满盘 · 转得更深", "count": 6, "packed": true, "minRotations": 3 },
{ "name": "收束前的深锁", "count": 5, "rotations": [4, 5], "packed": false },
{ "name": "终章 · 最满的几盘", "count": 6, "packed": true, "minFill": 1 }
{
"name": "开场 · 小满盘",
"count": 4,
"packed": true,
"maxCells": 37
},
{
"name": "长路线演出",
"count": 5,
"packed": false,
"maxPieces": 14,
"minLongestRoute": 30
},
{
"name": "深锁初见",
"count": 4,
"rotations": [
3,
4
],
"packed": false
},
{
"name": "环绕 · 同心圈",
"count": 3,
"families": [
"orbits"
]
},
{
"name": "花瓣 · 回到花心",
"count": 3,
"families": [
"petals"
]
},
{
"name": "满盘 · 由外向内",
"count": 2,
"packed": true
},
{
"name": "紧凑快节奏",
"count": 4,
"packed": false,
"maxPieces": 13,
"maxCells": 37
},
{
"name": "深锁 · 四转",
"count": 5,
"rotations": [
4,
5
],
"packed": false
},
{
"name": "大盘长路线",
"count": 4,
"packed": false,
"minCells": 61,
"minLongestRoute": 40
},
{
"name": "满盘 · 转得更深",
"count": 5,
"packed": true,
"minRotations": 3
},
{
"name": "螺旋",
"count": 3,
"families": [
"spiral"
]
},
{
"name": "交织收束",
"count": 3,
"families": [
"crossing",
"weave"
]
},
{
"name": "终章 · 全满收束",
"count": 5,
"packed": true,
"minFill": 1
}
]
+19
View File
@@ -9,6 +9,7 @@
// been played — --weights takes the refit, so a round is one flag, not an edit.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { trace } from '../src/core.js';
import { deepLevelKey, showStats } from '../src/deep-lock.js';
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
@@ -132,6 +133,16 @@ const spread = (rows, want) => {
return out;
};
// The longest line drawn on the board, which is what a level with few pieces and
// a winding route is selected for — the departure you watch rather than solve.
const routeCache = new WeakMap();
const longestRoute = level => {
if (routeCache.has(level)) return routeCache.get(level);
const n = Math.max(...level.tiles.map(t => trace(level, t, [], true).steps.length));
routeCache.set(level, n);
return n;
};
const shortfall = [];
if (PLAN) {
const density = row => row.level.tiles.length / row.level.cells.length;
@@ -142,6 +153,14 @@ if (PLAN) {
// difficulty curve from the outside in. A segment asks for one kind or the
// other outright rather than inferring it from density.
if (seg.packed != null && Boolean(p.packed) !== seg.packed) return false;
// Variety is a thing to ask for outright. An all-packed campaign reads as
// one level fifty times, and the families the brief singles out as the
// good-looking ones — 环绕 and 花瓣 — were absent from it entirely,
// filtered out upstream by a crossing threshold they sit just above.
if (seg.families && !seg.families.includes(p.family)) return false;
if (seg.maxPieces != null && r.level.tiles.length > seg.maxPieces) return false;
if (seg.minPieces != null && r.level.tiles.length < seg.minPieces) return false;
if (seg.minLongestRoute != null && longestRoute(r.level) < seg.minLongestRoute) return false;
if (seg.minFill != null && (p.fill ?? 0) < seg.minFill) return false;
if (seg.minRotations != null && (r.stats.rotations ?? 0) < seg.minRotations) return false;
if (seg.minCells != null && r.level.cells.length < seg.minCells) return false;
+6
View File
@@ -5,6 +5,7 @@ import { PRODUCTION_CAMPAIGN, PRODUCTION_LEVELS } from './levels-v05.js';
import { TUTORIAL_LEVELS } from './tutorial-levels.js';
import { C50_CAMPAIGN, C50_LEVELS } from './levels-c50.js';
import { C50B_CAMPAIGN, C50B_LEVELS } from './levels-c50b.js';
import { C50C_CAMPAIGN, C50C_LEVELS } from './levels-c50c.js';
// Public layout versions are distinct from the level JSON schema's `version: 1`.
// Historical layouts stay intact; all editions share the current game rules.
@@ -44,6 +45,11 @@ export const CAMPAIGN_VERSIONS = [
chapters:C50B_CAMPAIGN.chapters, chapterSize:C50B_CAMPAIGN.chapterSize, levels:C50B_LEVELS,
legacyStorageKeys:[],
},
{
id:C50C_CAMPAIGN.id, name:C50C_CAMPAIGN.name, description:C50C_CAMPAIGN.description,
chapters:C50C_CAMPAIGN.chapters, chapterSize:C50C_CAMPAIGN.chapterSize, levels:C50C_LEVELS,
legacyStorageKeys:[],
},
];
export const DEFAULT_CAMPAIGN_VERSION = 'v0.5';
export function campaignVersion(id) {
+13 -2
View File
@@ -1,8 +1,19 @@
// Generated by scripts/select-50.mjs --id=c50b — do not edit by hand.
//
// SEALED 2026-09-15, kept as a reference sample rather than as a candidate for
// shipping. 32 of its 50 levels are packed boards, which on playtest read as one
// level fifty times over: "体验太单一了,因为基本都是满盘". Two families the
// design brief calls out as the good-looking ones — 环绕 and 花瓣 — are absent
// from it entirely, filtered upstream by a crossing threshold of 1.0 that orbits
// sits just above (0% of orbits boards pass at 1.0, 84% pass at 1.2).
//
// It is worth keeping because the packed construction itself was judged good:
// the experience of clearing from the rim inward is what this set proved out.
// What it also proves is that one good idea at 64% is not a campaign.
export const C50B_CAMPAIGN = {
"id": "c50b",
"name": "五十关 · 满盘版",
"description": "外圈留空、内部填满的满盘为主,由外向内逐层清空;深锁关卡穿插其间做变奏。",
"name": "五十关 · 满盘版(封存)",
"description": "封存样本:满盘占 64%,实测体验过于单一。保留用于复盘满盘构造本身。",
"chapters": [
"起步",
"转起来",
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,7 +16,7 @@ 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],
['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],['c50c',50,10,5],
]);
assert.equal(campaignVersion('v0.1').levels[0].name,'转一转,路就通了');
assert.equal(campaignVersion('v0.1').levels.at(-1).name,'六角大满贯');