feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+90
View File
@@ -0,0 +1,90 @@
import { readFile, writeFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
import { generateExperienceLevel, inspectExperience, pointsInward, countBends } from '../src/experience-generator.js';
import { analyze, trace, rotateBoard, validateLevel, available } from '../src/core.js';
const root = new URL('../', import.meta.url);
const manifest = JSON.parse(await readFile(new URL('levels/v0.4-seeds.json', root), 'utf8'));
assert.equal(manifest.seeds.length, 20);
assert.equal(new Set(manifest.seeds.map(p => p.seed)).size, 20);
const levels = [], rows = [];
const kinds = { tour: '长路巡游', contrast: '轻巧反差', gate: '清挡放行', route: '旋转换路', return: '往返换路', stages: '分段释放', finale: '长路收束' };
for (const [index, recipe] of manifest.seeds.entries()) {
const label = `v0.4 ${index + 1} ${recipe.name}`;
assert.equal(recipe.level, index + 1, label);
const chapter = Math.floor(index / manifest.campaign.chapterSize);
const level = {
...generateExperienceLevel(recipe.seed, recipe.options),
id: `experience-v04-${String(index + 1).padStart(2, '0')}`,
name: recipe.name, chapter: chapter + 1, chapterName: manifest.campaign.chapters[chapter],
beat: kinds[recipe.kind], lesson: recipe.lesson, designIntent: recipe.designIntent,
experience: { kind: recipe.kind, feature: recipe.feature, actions: recipe.experienceActions },
};
assert.deepEqual(validateLevel(level), [], label);
assert.equal(level.tiles.length, recipe.options.target, label);
const a = analyze(level), reference = inspectExperience(level, a.actions), experience = inspectExperience(level, recipe.experienceActions);
assert.ok(a.solvable && !a.exhausted && reference.won && experience.won, label);
assert.equal(a.rotations, recipe.targets.rotations, label);
for (const q of [reference, experience]) {
assert.ok(q.maxBends >= recipe.targets.minMaxBends && q.longPaths >= recipe.targets.minLongPaths, label);
assert.ok(q.maxDryRotations <= 1, label);
}
assert.ok(reference.hidden.length <= recipe.targets.maxHidden, label);
assert.ok(experience.hidden.length <= recipe.targets.maxExperienceHidden, label);
if (recipe.kind === 'tour') assert.equal(a.free, level.tiles.length, label);
if (recipe.kind === 'contrast') {
const feature = recipe.feature, tile = level.tiles.find(t => t.id === feature.id);
const before = trace(level, tile), after = trace(rotateBoard(level, feature.direction), tile);
assert.ok(pointsInward(tile), label);
assert.equal(before.status, 'blocked', label);
assert.ok(trace(level, tile, level.tiles, true).blockers.includes(before.blocker), label);
assert.equal(after.status, 'exit', label);
assert.equal(countBends(after), feature.bends, label);
assert.deepEqual(recipe.experienceActions.slice(0, 2), [{ type: 'rotate', direction: feature.direction }, { type: 'tap', id: feature.id }], label);
}
if (recipe.kind === 'gate') assert.ok(reference.unlocks.some(u => u.id === recipe.feature.id && u.removed === recipe.feature.removed && u.onRoute && u.bends >= 8), label);
if (recipe.kind === 'return') assert.ok(reference.turns.length === 2 && reference.turns[0].direction === -reference.turns[1].direction, label);
if (recipe.kind === 'finale') assert.ok(reference.paths.slice(-5).filter(p => p.bends >= 10).length >= 3, label);
const orientations = Array.from({ length: 6 }, (_, turns) => {
const board = rotateBoard(level, turns), routes = level.tiles.map(t => trace(board, t));
return {
turns, free: available(board).length,
independentLoops: level.tiles.filter(t => trace(board, t, level.tiles, true).status === 'loop').map(t => t.id),
offRouteBlockers: routes.flatMap((r, i) => r.status === 'blocked' && !trace(board, level.tiles[i], level.tiles, true).blockers.includes(r.blocker) ? [{ id: level.tiles[i].id, blocker: r.blocker }] : []),
};
});
level.requiresRotation = a.rotations > 0;
levels.push(level);
rows.push({ level: index + 1, name: level.name, kind: recipe.kind, cells: level.cells.length, tiles: level.tiles.length, free: a.free, minimumRotations: a.rotations, structuralScore: a.score, reference, experience, orientations });
}
const referencePaths = rows.flatMap(r => r.reference.paths), curved = referencePaths.filter(p => p.bends > 0);
const summary = {
levels: levels.length, removals: referencePaths.length, curvedRemovals: curved.length,
curvedRatio: curved.length / referencePaths.length,
bendsPerCurvedPath: curved.reduce((s, p) => s + p.bends, 0) / curved.length,
maxBends: Math.max(...referencePaths.map(p => p.bends)),
longPaths: referencePaths.filter(p => p.bends >= 6).length,
rotationRequired: rows.filter(r => r.minimumRotations > 0).length,
immediateContrastLevels: rows.filter(r => r.kind === 'contrast').map(r => r.level),
};
const write = (path, text) => writeFile(new URL(path, root), text);
await write('levels/v0.4.json', JSON.stringify(levels, null, 2) + '\n');
await write('src/levels-v04.js', '// Generated by npm run levels:v04. Curated experience recipes; do not difficulty-sort.\nexport const EXPERIENCE_CAMPAIGN = ' + JSON.stringify(manifest.campaign) + ';\nexport const EXPERIENCE_LEVELS = ' + JSON.stringify(levels) + ';\n');
await write('levels/v0.4-analysis.json', JSON.stringify({ campaign: manifest.campaign, note: 'Reference and authored experience plans are complete replays. Six-orientation checks use the initial piece set. These are structural observations, not exhaustive exploration or player enjoyment data. Long paths in this report mean at least six bends; this is a v0.4 sample-selection band, not a universal design rule.', summary, levels: rows }, null, 2) + '\n');
let md = '# v0.4 · 长路与惊喜:20 关体验与回放\n\n依据 [新版设计准则](level-design-principles-v2.md),按 4 组 × 5 关编排。制作来源为 [种子与制作卡](../levels/v0.4-seeds.json),运行 `npm run levels:v04` 同步布局、模块和本报告。全部为新种子生成与筛选的布局,没有重用旧版关卡。\n\n';
md += `20 关参考解共 ${summary.removals} 次成功消除,其中 ${summary.longPaths} 条路径至少转弯 6 次;每条弯轨路径平均 ${summary.bendsPerCurvedPath.toFixed(2)} 弯,最长 ${summary.maxBends} 弯。${summary.rotationRequired} 关必须旋转,${summary.immediateContrastLevels.length} 关存在开局朝内棋子一次旋转后立即可点击离场的完整体验解。\n\n`;
md += '“长路”在本批统计中指至少 6 弯,仅用于样关比较。下表路径与旋转来自最少旋转参考解;反差关另有先转盘、再点击目标的完整体验解。结构分会给长路加分,不应把直接可清空的巡游关误判成高推理负担。没有真人用时或满意度数据。\n\n';
md += '| 关 | 名称 | 体验 | 格 / 棋子 | 开局可出 | 最少旋转 | 最长弯数 | 长路条数 | 参考 / 体验格外阻挡样本 |\n| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |\n';
md += rows.map(r => `| ${r.level} | ${r.name} | ${kinds[r.kind]} | ${r.cells} / ${r.tiles} | ${r.free} | ${r.minimumRotations} | ${r.reference.maxBends} | ${r.reference.longPaths} | ${r.reference.hidden.length} / ${r.experience.hidden.length} |`).join('\n');
md += '\n\n## 验收边界\n\n两套完整回放均可通关,相邻消除之间最多一次旋转;巡游关开局全可出;反差关目标确实朝内,开局被路线内棋子阻挡,左右其中一次旋转就获得无碰撞出口;清挡关有短路线释放至少 8 弯路径;往返关包含两个相反方向的必要旋转。\n\n本版恢复多阶段与长绕行,没有沿用 v0.3 全六朝向无闭环、参考状态格外阻挡为零的统一筛选。表中的格外阻挡样本按“决策状态 × 棋子”计,同一阻挡可重复出现;实际阻挡对象与接触反馈继续使用现有引擎。六朝向开局的闭环和擦碰明细保存在 [完整分析](../levels/v0.4-analysis.json),不能宣称所有探索路径都无闭环、无擦碰。替代顺序的可解性另由测试检查,真人可读性与完整动画手感仍需试玩。\n\n## 逐关制作卡\n\n';
for (const [i, r] of rows.entries()) {
const recipe = manifest.seeds[i];
const longest = r.experience.paths.reduce((a, b) => a.bends > b.bends ? a : b);
md += `### ${String(i + 1).padStart(2, '0')} · ${r.name}\n\n${recipe.designIntent}\n\n- 提示:${recipe.lesson}\n- 关键体验路径:\`${longest.id}\`,${longest.bends} 弯 / ${longest.cells} 格步骤。\n`;
if (r.kind === 'contrast') md += `- 反差操作:开局${recipe.feature.direction === 1 ? '右' : '左'}转一次,再点 \`${recipe.feature.id}\`(${recipe.feature.bends} 弯);箭头确实指向更内侧的六边形环。\n`;
if (r.kind === 'gate') md += `- 清挡证据:参考解第 ${recipe.feature.action + 1} 次操作移走 \`${recipe.feature.removed}\`,为 \`${recipe.feature.id}\` 打开 ${recipe.feature.bends} 弯路径。\n`;
if (r.reference.turns.length) md += `- 参考换路阶段:${r.reference.turns.map(t => `剩 ${t.remaining} 枚时${t.direction === 1 ? '右' : '左'}转`).join(' → ')}。\n`;
md += `- 完整体验动作:${recipe.experienceActions.map(a => a.type === 'tap' ? a.id : a.direction === 1 ? '右转' : '左转').join(' → ')}。\n\n`;
}
await write('docs/v0.4-experience-review.md', md);
console.log(JSON.stringify(summary, null, 2));