Files
HexTrails/scripts/build-experience-levels.mjs
T

91 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));