feat(levels): D5 three-rotation prototype

The shipped 1000 levels have a hole: 568 of them need no rotation at all, and
none needs more than two — the mechanic the game is built on is mostly
decoration. This fills the empty band with levels that need at least three.

createDeepLock builds a four-piece chain where each link only releases at a
later orientation, and growDeepLevel packs the rest of the board around it
without opening a shortcut. Candidate pools are built per orientation and left
untruncated; ranking by bend count first starved the packer and capped levels
at four to six pieces instead of twelve.

deadTurnAudit enumerates every reachable (orientation, remaining-set) state to
prove no dead ends exist, and counts how long a player can be left with
nothing to do — solvable and feeling solvable are different things, which is
what "level 2 is impossible" turned out to be about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 14:00:33 +08:00
co-authored by Claude Opus 5
parent f3e91e3597
commit 643cba0ed9
8 changed files with 27443 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
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.
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;
while (queue.length && seen.size < maxStates) {
const state = queue.shift();
if (!state.rem.size) continue;
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, exhausted: seen.size >= maxStates };
}
export const deepLevelKey = level => level.tiles.map(t => `${t.q},${t.r},${t.dir}`).sort().join('|');
export { key as cellKey };