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
+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;