// 关卡设计辅助。GDD §8.2 的第 3–5 步:挑线索、验唯一、删到最少。 // // npm run design -- 1-7 看这关:全部无线索路径、当前线索下的解 // npm run design -- 1-7 --minimize 当前线索里哪些可以删掉仍唯一 // npm run design -- 1-7 --suggest 2 以当前解为“作者解”,找 2 个线索(含 1)就唯一的方案 // npm run design -- file.json 也可以直接指定一个临时关卡文件(单关 LevelDef 或整包) import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Board } from '../src/board'; import { clueLabel, hasClue } from '../src/clue'; import { parsePack } from '../src/levels'; import { solve } from '../src/solver'; import type { CellDef, LevelDef } from '../src/types'; import { pathToIds, renderAscii } from './ascii'; const here = dirname(fileURLToPath(import.meta.url)); const args = process.argv.slice(2); const target = args[0]; if (!target) { console.log('用法: npm run design -- <关卡id|json文件> [--minimize] [--suggest k] [--path a,b,c]'); process.exit(1); } function findLevel(idOrFile: string): LevelDef { if (idOrFile.endsWith('.json') && existsSync(idOrFile)) { const raw = JSON.parse(readFileSync(idOrFile, 'utf8')); if (Array.isArray(raw.levels)) return parsePack(raw).levels[0]; return raw as LevelDef; } const dir = join(here, '..', 'levels'); for (const f of readdirSync(dir).filter((x) => x.endsWith('.json'))) { const pack = parsePack(JSON.parse(readFileSync(join(dir, f), 'utf8'))); const lv = pack.levels.find((l) => l.id === idOrFile); if (lv) return lv; } throw new Error(`找不到关卡 ${idOrFile}`); } const level = findLevel(target); const board = Board.fromLevel(level); const clueCells = board.cells.filter((c) => c.clue !== null); console.log(`\n${level.id} N=${board.n} 线索: ${board.cells.filter(hasClue).map((c) => `${c.id}=${clueLabel(c)}`).join(' ') || '无'}`); console.log(renderAscii(board)); const allPaths = solve(board, { ignoreClues: true, limit: 2000 }); console.log(`\n无线索哈密顿路径: ${allPaths.length >= 2000 ? '2000+' : allPaths.length}`); const withClues = solve(board, { limit: 50 }); console.log(`当前线索下的解: ${withClues.length}${withClues.length >= 50 ? '+' : ''}`); for (const p of withClues.slice(0, 8)) console.log(' ' + pathToIds(board, p)); if (withClues.length === 1) console.log(renderAscii(board, withClues[0])); // 作者解:--path 指定,否则用当前唯一解 let author: number[] | null = null; const pathArg = args.indexOf('--path'); if (pathArg !== -1) { author = args[pathArg + 1].split(',').map((id) => board.cellById(id.trim()).index); } else if (withClues.length === 1) { author = withClues[0]; } function countWithClues(clues: Map, limit = 2): number { const defs: CellDef[] = level.cells.map(({ clue, ...c }, index) => ({ ...c, ...(clues.has(index) ? { clue: clues.get(index)! } : {}), })); return solve(Board.fromCells(defs), { limit }).length; } if (args.includes('--minimize') && withClues.length === 1) { console.log('\n[minimize] 尝试删线索:'); const current = new Map(clueCells.map((c) => [c.index, c.clue!])); const removable: string[] = []; for (const c of clueCells) { const trial = new Map(current); trial.delete(c.index); const n = countWithClues(trial); console.log(` 去掉 ${c.id}=${c.clue} → ${n === 1 ? '仍唯一 ✓' : n === 0 ? '无解' : '多解'}`); if (n === 1) removable.push(`${c.id}=${c.clue}`); } for (const c of board.cells.filter((cell) => cell.parity !== null)) { const defs = level.cells.map((d) => { const { parity, ...rest } = d; return d.id === c.id ? rest : d; }); const n = solve(Board.fromCells(defs), { limit: 2 }).length; console.log(` 去掉 ${c.id}=${clueLabel(c)} → ${n === 1 ? '仍唯一 ✓' : '多解'}`); if (n === 1) removable.push(`${c.id}=${clueLabel(c)}`); } console.log(removable.length ? ` 单独可删: ${removable.join(', ')}` : ' 每个线索都必要'); } const suggestArg = args.indexOf('--suggest'); if (suggestArg !== -1) { if (!author) { console.log('\n[suggest] 需要一条作者解:先让当前线索唯一,或用 --path 指定'); } else { const k = Number(args[suggestArg + 1] ?? 2); const n = author.length; const keepStart = clueCells.some((c) => c.clue === 1); console.log(`\n[suggest] 作者解: ${pathToIds(board, author)}`); console.log(` 找 ${k} 个线索${keepStart ? '(含起点 1)' : ''} 使其唯一,按“少用端点、分布均匀”排序:`); const positions: number[] = []; for (let s = 1; s <= n; s++) if (board.cells[author[s - 1]].parity === null) positions.push(s); const results: { clues: number[]; score: number }[] = []; const combo: number[] = []; const startFixed = keepStart ? [1] : []; const pool = positions.filter((s) => !startFixed.includes(s)); const need = k - startFixed.length; function rec(from: number): void { if (combo.length === need) { const steps = [...startFixed, ...combo]; const clues = new Map(steps.map((s) => [author![s - 1], s])); if (countWithClues(clues) === 1) { // 端点扣分;线索之间间距越均匀越好 const sorted = steps.slice().sort((a, b) => a - b); let score = 0; for (const s of sorted) if (s === 1 || s === n) score += 3; for (let i = 1; i < sorted.length; i++) score += Math.abs(sorted[i] - sorted[i - 1] - n / (sorted.length + 1)) * 0.2; results.push({ clues: steps, score }); } return; } for (let i = from; i < pool.length; i++) { combo.push(pool[i]); rec(i + 1); combo.pop(); } } rec(0); results.sort((a, b) => a.score - b.score); if (!results.length) console.log(' 没有方案,试试 k+1'); for (const r of results.slice(0, 12)) { const desc = r.clues .slice() .sort((a, b) => a - b) .map((s) => `${board.cells[author![s - 1]].id}=${s}`) .join(' '); console.log(` ${desc}`); } if (results.length > 12) console.log(` … 共 ${results.length} 个方案`); } }