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:
@@ -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 };
|
||||
Reference in New Issue
Block a user