import { Game, available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js'; import { exitTiming } from './exit-animation.js'; import { MOTION } from './design-tokens.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 }; // All six orientations: the chain only constrains 0/1/2, but the endgame // is played at 3/4/5 too, and leaving those to chance is what produced // locks that open at exactly one of the six. const routes = boards.map(board => trace(board, tile, [], true)); if (routes.slice(0, 3).some(route => route.status !== 'exit')) continue; const bends = routes.map(r => r.status === 'exit' ? countBends(r) : Infinity); if (bends[1] > maxBends) continue; pool.push({ tile, position: positionKey(tile), bends, cells: routes.map(route => route.steps.length), exits: routes.map(route => route.status === 'exit'), 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, 3, 4, 5].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. // A and D deadlock at 0 and at +2 and open at +1 — that is the chain. The // added requirement is that +3 opens too: a player stuck at +2 turns one // way or the other and something moves either way, instead of one // direction being right and the other five a dead end. 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) && ((a.exits[3] && !a.at[3].has(d.position)) || (d.exits[3] && !d.at[3].has(a.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; // Certify the two-sided release with the real swept tracer, not the // cell-occupancy approximation the filter above uses. if (![hero, tail].some(t => trace(boards[3], t, afterSecond).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. // Rotation is free and it is what the game is about, so depth is the thing to // grow, not cap. Scheduling pieces at orientations 3/4/5 as well as 0/1/2 is // what lets a level need five or six turns instead of always exactly three — // and each of those extra turns pays out, because the piece scheduled there is // waiting to leave. export function growDeepLevel(base, lock, { seed = 0, depth = 3, perTurn = 2, maxHeroBends = 70, minRotations = 3, target = null, showcase = 2, showcaseMinDistance = 6, showcaseMaxDistance = 20, } = {}) { const rng = random(seed + 7919); const boards = [0, 1, 2, 3, 4, 5].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]; // Depth comes from a quota per orientation, not from random shares. Shares // left some orientations empty, and an orientation with nothing waiting is a // turn that pays nothing — the one thing that reliably reads as boring. const schedule = []; for (let turn = 0; turn < depth; turn++) for (let i = 0; i < perTurn; i++) schedule.push(turn); if (target != null) schedule.length = Math.max(0, Math.min(schedule.length, target - tiles.length)); let rejected = { orientationless: 0, shortcut: 0, unfilled: 0 }; for (const orientation of [...schedule].sort((a, b) => b - a)) { 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; // One orientation running out of candidates must not abandon the rest. if (!chosen) { rejected.unfilled++; continue; } tiles.push(chosen); placements.push(orientation); } // The chain locks everything at the opening by construction, so a level built // only from it hands a casual player two rim pieces and a third of a second // of animation before the thinking starts. Reserve a couple of slots for // pieces that are tappable immediately and whose route is worth watching: // long enough to read as a journey, short enough not to hit the ceiling. for (let i = 0; i < showcase; i++) { const occupied = new Set(tiles.map(positionKey)); let best = null; for (const c of shuffled(layerPool[0].filter(x => !occupied.has(x.position)), rng)) { const tile = { ...c.tile, id: `t${tiles.length}`, color: tiles.length % 7 }, proposed = [...tiles, tile]; const route = trace(boards[0], tile, proposed); if (route.status !== 'exit') continue; const { distance } = exitTiming(route); if (distance < showcaseMinDistance || distance > showcaseMaxDistance) continue; // It must not shortcut the lock it is decorating. const solution = solveRotations({ ...base, tiles: proposed, moveLimit: proposed.length }); if (!solution.solvable || solution.exhausted || solution.rotations < minRotations) continue; if (!best || distance > best.distance) best = { tile, distance }; } if (!best) break; tiles.push(best.tile); placements.push(0); } 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. // How far a piece swings between "looks hopeless" and "walks straight out". // Cost is what the eye judges from: cells travelled plus twice the bends. A // piece that reads as a 128-cost tangle at one orientation and a 5-cost stroll // at another is where the satisfaction comes from; families whose pieces all // sit around the same cost no matter how the board turns feel mechanical. export function contrastStats(level) { const boards = [0, 1, 2, 3, 4, 5].map(k => rotateBoard(level, k)); const cost = route => route.status === 'exit' ? route.steps.length + 2 * countBends(route) : null; const HOPELESS = 60; const swings = []; for (const tile of level.tiles) { const costs = boards.map(board => cost(trace(board, tile, [], true))); const reachable = costs.filter(c => c != null); if (!reachable.length) continue; const best = Math.min(...reachable); const opening = trace(level, tile, level.tiles); const seen = opening.status === 'exit' ? cost(opening) : (costs[0] ?? HOPELESS); swings.push(seen - best); } if (!swings.length) return { contrast: 0, maxSwing: 0 }; return { contrast: swings.reduce((a, b) => a + b, 0) / swings.length, maxSwing: Math.max(...swings), }; } // What it costs to know what you may tap. A player verifies a piece by // following its line until it leaves the board or meets somebody; length and // bends are what that costs the eye. This is the difficulty the design brief // asks for — 无法一下子判断 — and it is not board size: a wide board whose // nearest free piece is three cells away reads instantly, while a small one // where every free piece hides behind a thirty-cell switchback does not. // // Grazes are counted apart because they are not difficulty at all, they are a // lie. A piece can have an empty line and still be held, by a neighbour the // route never touches; following that line tells the player it is free, and // the tap does nothing. That is the "many actions, no result" the brief rules // out, so a level wants none of these at its opening. export function legibility(level) { const occupant = new Map(level.tiles.map(t => [`${t.q},${t.r}`, t.id])); const costs = []; let grazeTraps = 0; for (const tile of level.tiles) { const geo = trace(level, tile, [], true); // the drawn line, ignoring who sits on it const real = trace(level, tile, level.tiles); // where the piece would actually stop const onRoute = geo.steps.some(s => { const who = occupant.get(`${s.q},${s.r}`); return who && who !== tile.id; }); if (real.status !== 'exit' && !onRoute) grazeTraps++; // Verification stops where the eye stops: at the blocker, or at the edge. const seen = real.status === 'exit' ? geo : real; costs.push({ id: tile.id, free: real.status === 'exit', cost: seen.steps.length + 2 * countBends(seen) }); } const free = costs.filter(c => c.free).map(c => c.cost); const all = costs.map(c => c.cost).sort((a, b) => a - b); return { glanceCost: free.length ? Math.min(...free) : null, // the cheapest correct move to find medianVerify: all.length ? all[Math.floor(all.length / 2)] : 0, grazeTraps, }; } // What the player actually watches. Rotation costs no steps precisely so that // people try things; the payout is the animation of a piece threading a winding // route and leaving. So the show is measurable: how long each departure plays, // whether the level opens with one, and — the failure the design brief names // outright — how many actions can pass with nothing happening at all. // What counts as a cruise, and what counts as an opening worth watching, were // both picked in milliseconds while a piece crossed one unit in 120ms. Speed is // a tuning dial, so holding those milliseconds fixed while the dial moves does // not hold the experience fixed — it silently demands longer routes. Raising // the speed once made 20 of 50 shipped levels "fail" an opening gate they had // passed, purely from the unscaled number. They scale with the dial instead. const SHOW_CALIBRATED_AT = 120; const showScale = () => MOTION.exitMsPerUnit / SHOW_CALIBRATED_AT; export const CRUISE_MS_AT_120 = 900; const CRUISE_MS = CRUISE_MS_AT_120 * showScale(); export const openingShowFloor = (msAt120 = 800) => msAt120 * showScale(); export function showStats(level) { const solution = solveRotations(level); if (!solution.solvable || !solution.actions) return null; const game = new Game(level); const shows = []; let dry = 0, maxDry = 0, firstShow = null, sinceStart = 0; for (const action of solution.actions) { if (action.type === 'rotate') { game.rotate(action.direction); dry++; sinceStart++; maxDry = Math.max(maxDry, dry); continue; } const result = game.tap(action.id); sinceStart++; if (result.status === 'exit') { const ms = exitTiming(result).duration; shows.push(ms); if (firstShow === null) firstShow = { ms, afterActions: sinceStart }; dry = 0; } else { dry++; maxDry = Math.max(maxDry, dry); } } if (!shows.length) return null; const sorted = [...shows].sort((a, b) => a - b); // What the player can see straight away if they pick well, rather than what // the optimal script happens to tap first. An opening whose only departure is // a piece already touching the rim plays for 0.3s and shows nothing. const opening = new Game(level); const openingShows = available(level, opening.tiles) .map(t => exitTiming(trace(level, t, opening.tiles)).duration); return { exits: shows.length, openingChoices: openingShows.length, openingBestMs: openingShows.length ? Math.round(Math.max(...openingShows)) : 0, totalMs: Math.round(shows.reduce((a, b) => a + b, 0)), medianMs: Math.round(sorted[Math.floor(sorted.length / 2)]), longestMs: Math.round(sorted[sorted.length - 1]), // A cruise: long enough that watching it is the point, not a blink. cruises: shows.filter(ms => ms >= CRUISE_MS).length, firstShowMs: firstShow ? Math.round(firstShow.ms) : null, firstShowAfter: firstShow ? firstShow.afterActions : null, maxDryActions: maxDry, }; } 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; // How busy the drawing is, as two numbers taken from the same data. A cell's // six ports are paired into three chords; a chord joining opposite ports is a // diameter, drawn as a straight line, and two diameters in one cell always // cross. So "the board is all straight lines" and "there are too many crossing // points" are one property seen twice — which is why the filler that paired // leftover ports straight across produced both at once. export function trackShape(level) { let pairs = 0, straight = 0, crossings = 0, busiest = 0; for (const cell of level.cells) { const seen = new Set(), chords = []; for (let port = 0; port < 6; port++) { if (seen.has(port)) continue; const other = cell.tracks[port]; seen.add(port); seen.add(other); chords.push([port, other]); } const between = (x, a, b) => { for (let k = (a + 1) % 6; k !== b; k = (k + 1) % 6) if (k === x) return true; return false; }; let here = 0; for (let i = 0; i < chords.length; i++) { pairs++; if ((chords[i][0] + 3) % 6 === chords[i][1]) straight++; for (let j = i + 1; j < chords.length; j++) { const [a, b] = chords[i], [c, d] = chords[j]; if (between(c, a, b) !== between(d, a, b)) here++; } } crossings += here; busiest = Math.max(busiest, here); } return { straightShare: pairs ? straight / pairs : 0, crossPerCell: level.cells.length ? crossings / level.cells.length : 0, crossings, busiestCell: busiest, }; } // The endgame question — with a handful of pieces left, how many of the six // orientations release anything — used to be answered from the same breadth // search that counts dead turns. On a dense board that search hits its state // budget long before it reaches any endgame, so it reported `null`, and the // gate `endgameMinOpen >= 3` read null as a failure. Every level past about // eleven pieces was rejected for an endgame nobody had measured. That is why // no level in this project has ever been more than a third pieces. // // Enumerating the endgames directly costs nothing by comparison — C(26,3) is // 2600 — and it finishes, so a dense board gets a real answer. Reachability is // not checked: the enumeration is a superset of the endgames a player can // arrive at, which errs toward caution rather than toward a silent pass. // // The statistic is the SHARE of endgames that are tight, not the minimum over // them. A minimum taken over more subsets falls for arithmetic reasons alone — // the same confound that made `doubleDead` look eight times worse for deep // levels when the rate was 2% against 5%. By share, 26-piece boards come in at // 0.0% against 0.1% for 11-piece ones: they are not worse, they were unmeasured. export function endgameScan(level, k = ENDGAME_PIECES) { const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i)); const ids = level.tiles.map(t => t.id); const counts = {}; let subsets = 0, minOpen = 6, worst = null; const visit = (from, pick) => { if (pick.length) { const tiles = level.tiles.filter(t => pick.includes(t.id)); let open = 0; for (let o = 0; o < 6; o++) if (available(boards[o], tiles).length) open++; counts[open] = (counts[open] || 0) + 1; subsets++; if (open < minOpen) { minOpen = open; worst = [...pick].sort().join(','); } } if (pick.length === k) return; for (let i = from; i < ids.length; i++) visit(i + 1, [...pick, ids[i]]); }; visit(0, []); const tight = (counts[0] || 0) + (counts[1] || 0); return { subsets, minOpen, worst, tightStates: tight, tightShare: subsets ? tight / subsets : 0 }; } 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); } } } const endgame = endgameScan(level); return { states: seen.size, stuckStates, doubleDead, maxDeadTurns, unsolvable, // Endgame numbers come from the complete enumeration, never from this // search — it may not have reached an endgame at all. endgameMinOpen: endgame.minOpen, endgameTightStates: endgame.tightStates, endgameTightShare: endgame.tightShare, endgameSubsets: endgame.subsets, endgameWorst: endgame.worst, exhausted: seen.size >= maxStates }; } export const deepLevelKey = level => level.tiles.map(t => `${t.q},${t.r},${t.dir}`).sort().join('|'); export { key as cellKey };