import { available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js'; const positionKey = tile => `${tile.q},${tile.r}`; const countBends = route => route.steps.filter(step => step.enter !== null && opposite(step.enter) !== step.exit).length; function shuffled(items, rng) { const result = [...items]; for (let i = result.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; } // Every candidate carries its independent route at orientations 0, 1 and 2. // Grid membership only proposes a lock; the swept tracer certifies it below. function candidatePool(base, boards, maxBends) { const pool = []; for (const cell of base.cells) for (let dir = 0; dir < 6; dir++) { const tile = { q: cell.q, r: cell.r, dir }; const routes = [0, 1, 2].map(turn => trace(boards[turn], tile, [], true)); if (routes.some(route => route.status !== 'exit')) continue; const bends = routes.map(countBends); if (bends[1] > maxBends) continue; pool.push({ tile, position: positionKey(tile), bends, cells: routes.map(route => route.steps.length), at: routes.map(route => new Set(route.steps.slice(1).map(positionKey))), }); } return pool; } // A/B/C/D is a three-turn chain, not three independent long routes. // At 0 all four are locked. At +1 only B can leave. At +2 only C can leave; // A and D still hold each other. Turning back to +1 frees A, then D. // The solver certifies that no one- or two-turn plan clears the board. export function createDeepLock(base, { seed = 0, minHeroBends = 6, maxHeroBends = 70, maxChecks = 2000000, } = {}) { const rng = random(seed); const boards = [0, 1, 2].map(turn => rotateBoard(base, turn)); const pool = candidatePool(base, boards, maxHeroBends); const candidates = shuffled(pool, rng); const heroes = candidates.filter(c => c.bends[1] >= minHeroBends); let checks = 0, formalChecks = 0; for (const a of heroes) { // D holds A at 0 and at +2, and never stands on A's +1 departure. const tails = candidates.filter(d => d.position !== a.position && a.at[0].has(d.position) && d.at[0].has(a.position) && a.at[2].has(d.position) && d.at[2].has(a.position) && !a.at[1].has(d.position)); for (const d of tails) { // C holds A at +1 and leaves alone at +2 once B is gone. const seconds = candidates.filter(c => c.position !== a.position && c.position !== d.position && a.at[1].has(c.position) && !c.at[2].has(a.position) && !c.at[2].has(d.position)); for (const c of seconds) { // B is A's blocker at 0 and is the only piece that leaves at +1. const gates = candidates.filter(b => b.position !== a.position && b.position !== c.position && b.position !== d.position && a.at[0].has(b.position) && !b.at[1].has(a.position) && !b.at[1].has(c.position) && !b.at[1].has(d.position) && (c.at[1].has(a.position) || c.at[1].has(d.position) || c.at[1].has(b.position))); for (const b of gates) { if (++checks > maxChecks) return null; const tiles = [a, b, c, d].map((candidate, i) => ({ ...candidate.tile, id: `t${i}`, color: i })); const [hero, gate, second, tail] = tiles; formalChecks++; // Formal certification of every stage with the production swept tracer. if (tiles.some(tile => trace(boards[0], tile, tiles).status === 'exit')) continue; if (trace(boards[1], gate, tiles).status !== 'exit') continue; const afterGate = [hero, second, tail]; if (afterGate.some(tile => trace(boards[1], tile, afterGate).status === 'exit')) continue; if (trace(boards[2], second, afterGate).status !== 'exit') continue; const afterSecond = [hero, tail]; if (afterSecond.some(tile => trace(boards[2], tile, afterSecond).status === 'exit')) continue; if (trace(boards[1], hero, afterSecond).status !== 'exit') continue; if (trace(boards[1], tail, [tail]).status !== 'exit') continue; const level = { ...base, tiles, moveLimit: tiles.length }; const solution = solveRotations(level); if (!solution.solvable || solution.exhausted || solution.rotations !== 3) continue; return { tiles, placements: [1, 1, 2, 1], hero, heroRoute: { bends: a.bends[1], cells: a.cells[1] }, actions: [ { type: 'rotate', direction: 1 }, { type: 'tap', id: gate.id }, { type: 'rotate', direction: 1 }, { type: 'tap', id: second.id }, { type: 'rotate', direction: -1 }, { type: 'tap', id: hero.id }, { type: 'tap', id: tail.id }, ], minimumRotations: solution.rotations, solution, search: { checks, formalChecks, heroCandidates: heroes.length, poolSize: pool.length }, }; } } } } return null; } // Reverse insertion keeps the chain intact while raising how much of the // board depends on turning. Each inserted piece must leave at its scheduled // orientation, and the solver must still need exactly three turns afterwards. export function growDeepLevel(base, lock, { seed = 0, target = 12, layerShares = { 0: .35, 1: .35, 2: .3 }, maxHeroBends = 70, minRotations = 3, } = {}) { const rng = random(seed + 7919); const boards = [0, 1, 2].map(turn => rotateBoard(base, turn)); // A layer piece only needs a departure at its own orientation. Requiring // exits at every orientation would leave almost nothing to insert. const layerPool = boards.map(board => { const pool = []; for (const cell of base.cells) for (let dir = 0; dir < 6; dir++) { const tile = { q: cell.q, r: cell.r, dir }, route = trace(board, tile, [], true); if (route.status === 'exit' && countBends(route) <= maxHeroBends) pool.push({ tile, position: positionKey(tile), bends: countBends(route) }); } return pool; }); const tiles = [...lock.tiles], placements = [...lock.placements]; const extras = Math.max(0, target - tiles.length); const schedule = []; for (const [turn, share] of Object.entries(layerShares)) for (let i = 0; i < Math.round(extras * share); i++) schedule.push(Number(turn)); while (schedule.length < extras) schedule.push(0); schedule.length = extras; let rejected = { orientationless: 0, shortcut: 0 }; for (const orientation of shuffled(schedule, rng)) { const occupied = new Set(tiles.map(positionKey)); const choices = shuffled(layerPool[orientation].filter(c => !occupied.has(c.position)), rng); let chosen = null, fallback = null; for (const c of choices) { const tile = { ...c.tile, id: `t${tiles.length}`, color: tiles.length % 7 }, proposed = [...tiles, tile]; if (trace(boards[orientation], tile, proposed).status !== 'exit') { rejected.orientationless++; continue; } // Prefer pieces that genuinely wait for this turn: blocked at every earlier orientation. const waits = [...Array(orientation).keys()].every(earlier => trace(boards[earlier], tile, proposed).status !== 'exit'); if (!waits && fallback) continue; const solution = solveRotations({ ...base, tiles: proposed, moveLimit: proposed.length }); if (!solution.solvable || solution.exhausted || solution.rotations < minRotations) { rejected.shortcut++; continue; } if (waits) { chosen = tile; break; } fallback = tile; } chosen = chosen || fallback; if (!chosen) break; tiles.push(chosen); placements.push(orientation); } const level = { ...base, tiles, moveLimit: tiles.length, name: `${(base.name || 'route').replace(' · 路线草图', '')} · 深旋转`, experience: { kind: 'deep', feature: { id: lock.hero.id } }, }; const solution = solveRotations(level); level.experience.actions = solution.actions; const errors = validateLevel(level); if (errors.length) throw new Error(errors.join('; ')); return { level, placements, solution, rejected }; } // Structural evidence for the difficulty bands: how much of the board waits // for a turn, how many pieces each turn releases, and whether the direction // of the first turn is a real choice. export function deepLevelStats(level) { const n = level.tiles.length, solution = solveRotations(level); let board = level, remaining = [...level.tiles]; const drain = () => { for (;;) { const free = available(board, remaining); if (!free.length) return; const ids = new Set(free.map(t => t.id)); remaining = remaining.filter(t => !ids.has(t.id)); } }; drain(); const lockedAtStart = remaining.length; const openings = [1, -1].filter(direction => available(rotateBoard(board, direction), remaining).length > 0).length; const releases = []; for (const action of solution.actions) { if (action.type !== 'rotate') continue; const before = remaining.length; board = rotateBoard(board, action.direction); drain(); releases.push(before - remaining.length); } return { pieces: n, rotations: solution.rotations, lockedShare: lockedAtStart / n, lockedAtStart, firstTurnDirections: openings, releasePerTurn: releases, minRelease: releases.length ? Math.min(...releases) : 0, waves: solution.waves.map(wave => wave.length), states: solution.states, }; } // Player-side audit over every reachable state (any tap order, any turn // sequence). Removal only reduces occupancy and turns are reversible, so no // reachable state can be unsolvable — the audit proves it rather than assuming // it — and it measures how many blind turns a player can run into. // A level is judged on its endgame with this many pieces or fewer left. const ENDGAME_PIECES = 3; export function deadTurnAudit(level, maxStates = 20000) { const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i)); const keyOf = (o, rem) => o + ':' + [...rem].sort().join(','); const start = { o: 0, rem: new Set(level.tiles.map(t => t.id)) }; const seen = new Map([[keyOf(0, start.rem), start]]), queue = [start]; let stuckStates = 0, doubleDead = 0, maxDeadTurns = 0, unsolvable = 0; // The endgame is where every model complained: two pieces left, nothing on // the board saying which way to turn. Count how many of the six orientations // release anything at all — one is a 1-in-6 guess with a dead end behind // every other door, which is what made an agent give up on a solvable level. const openCache = new Map(); let endgameMinOpen = 6, endgameTightStates = 0, endgameWorst = null; const openOrientations = rem => { const k = [...rem].sort().join(','); if (openCache.has(k)) return openCache.get(k); const tiles = level.tiles.filter(t => rem.has(t.id)); let n = 0; for (let o = 0; o < 6; o++) if (available(boards[o], tiles).length) n++; openCache.set(k, n); return n; }; while (queue.length && seen.size < maxStates) { const state = queue.shift(); if (!state.rem.size) continue; if (state.rem.size <= ENDGAME_PIECES) { const open = openOrientations(state.rem); if (open <= 1) endgameTightStates++; if (open < endgameMinOpen) { endgameMinOpen = open; endgameWorst = [...state.rem].sort().join(','); } } const tiles = level.tiles.filter(t => state.rem.has(t.id)), free = available(boards[state.o], tiles); if (!free.length) { stuckStates++; if (![1, -1].some(d => available(boards[(state.o + d + 6) % 6], tiles).length)) doubleDead++; let nearest = 6; for (const d of [1, -1]) for (let k = 1; k < 6; k++) if (available(boards[(state.o + d * k + 6) % 6], tiles).length) { nearest = Math.min(nearest, k); break; } if (nearest === 6) unsolvable++; else maxDeadTurns = Math.max(maxDeadTurns, nearest - 1); } for (const t of free) { const rem = new Set(state.rem); rem.delete(t.id); const k = keyOf(state.o, rem); if (!seen.has(k)) { const next = { o: state.o, rem }; seen.set(k, next); queue.push(next); } } for (const d of [1, -1]) { const o = (state.o + d + 6) % 6, k = keyOf(o, state.rem); if (!seen.has(k)) { const next = { o, rem: state.rem }; seen.set(k, next); queue.push(next); } } } return { states: seen.size, stuckStates, doubleDead, maxDeadTurns, unsolvable, endgameMinOpen: endgameMinOpen === 6 && !openCache.size ? null : endgameMinOpen, endgameTightStates, endgameWorst, exhausted: seen.size >= maxStates }; } export const deepLevelKey = level => level.tiles.map(t => `${t.q},${t.r},${t.dir}`).sort().join('|'); export { key as cellKey };