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,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Game, rotateBoard, solveRotations, trace, validateLevel } from '../src/core.js';
|
||||
import { createRouteBoard } from '../src/route-grammar.js';
|
||||
import { createDeepLock, growDeepLevel, deepLevelStats, deadTurnAudit } from '../src/deep-lock.js';
|
||||
|
||||
// Seeds known to yield a chain lock; the sweep script records the full yield table.
|
||||
const WITNESSES = [
|
||||
{ family: 'orbits', radius: 3, seed: 0 },
|
||||
{ family: 'weave', radius: 4, seed: 0 },
|
||||
{ family: 'spiral', radius: 4, seed: 1 },
|
||||
{ family: 'switchback', radius: 4, seed: 0 },
|
||||
];
|
||||
|
||||
for (const { family, radius, seed } of WITNESSES) {
|
||||
test(`${family} r${radius}: three-turn chain is certified stage by stage and by the solver`, () => {
|
||||
const base = createRouteBoard(seed, { family, radius });
|
||||
const snapshot = JSON.stringify(base);
|
||||
const lock = createDeepLock(base, { seed });
|
||||
assert.ok(lock, `${family}, radius ${radius}, seed ${seed}`);
|
||||
assert.equal(JSON.stringify(base), snapshot, 'The helper must not alter its input route');
|
||||
assert.equal(lock.minimumRotations, 3);
|
||||
assert.ok(lock.heroRoute.bends >= 6);
|
||||
|
||||
const level = { ...base, tiles: lock.tiles, moveLimit: lock.tiles.length };
|
||||
assert.deepEqual(validateLevel(level), []);
|
||||
assert.equal(solveRotations(level).rotations, 3, 'No one- or two-turn plan may clear the chain');
|
||||
const [hero, gate, second, tail] = lock.tiles;
|
||||
const at = turn => rotateBoard(level, turn);
|
||||
assert.ok(lock.tiles.every(tile => trace(level, tile).status === 'blocked'), 'Everything is locked at the opening');
|
||||
assert.equal(trace(at(1), gate).status, 'exit', 'Only the gate leaves at +1');
|
||||
assert.ok([hero, second, tail].every(tile => trace(at(1), tile, [hero, second, tail]).status === 'blocked'));
|
||||
assert.equal(trace(at(2), second, [hero, second, tail]).status, 'exit', 'The second gate leaves at +2');
|
||||
assert.equal(trace(at(2), hero, [hero, tail]).status, 'blocked', 'Hero and tail hold each other at +2');
|
||||
assert.equal(trace(at(2), tail, [hero, tail]).status, 'blocked');
|
||||
assert.equal(trace(at(1), hero, [hero, tail]).status, 'exit', 'Turning back frees the hero');
|
||||
|
||||
const game = new Game(level);
|
||||
for (const action of lock.actions) {
|
||||
if (action.type === 'rotate') game.rotate(action.direction);
|
||||
else assert.equal(game.tap(action.id).status, 'exit');
|
||||
}
|
||||
assert.equal(game.tiles.length, 0, 'The authored three-turn witness must fully clear the board');
|
||||
assert.equal(game.rotations, 3);
|
||||
});
|
||||
|
||||
test(`${family} r${radius}: growth keeps three turns and locks most of the board`, () => {
|
||||
const base = createRouteBoard(seed, { family, radius });
|
||||
const lock = createDeepLock(base, { seed });
|
||||
const target = radius === 3 ? 9 : 12;
|
||||
const { level, solution, placements } = growDeepLevel(base, lock, { seed, target });
|
||||
assert.deepEqual(validateLevel(level), []);
|
||||
assert.ok(level.tiles.length >= target - 2, `grew to ${level.tiles.length} of ${target}`);
|
||||
assert.equal(placements.length, level.tiles.length);
|
||||
assert.ok(solution.solvable && !solution.exhausted);
|
||||
assert.ok(solution.rotations >= 3, 'Inserted pieces must not open a shorter plan');
|
||||
const stats = deepLevelStats(level);
|
||||
assert.ok(stats.lockedShare >= .5, `locked share ${stats.lockedShare}`);
|
||||
assert.equal(stats.releasePerTurn.length, stats.rotations);
|
||||
// Every state a player can reach — any tap order, any turn sequence — must still clear.
|
||||
const audit = deadTurnAudit(level);
|
||||
assert.ok(!audit.exhausted, 'audit must enumerate every reachable state');
|
||||
assert.equal(audit.unsolvable, 0, 'no reachable state may be a dead end');
|
||||
const game = new Game(level);
|
||||
for (const action of level.experience.actions) {
|
||||
if (action.type === 'rotate') game.rotate(action.direction);
|
||||
else assert.equal(game.tap(action.id).status, 'exit');
|
||||
}
|
||||
assert.ok(game.won, 'The solver reference must clear the grown level');
|
||||
});
|
||||
}
|
||||
|
||||
test('deep lock search is deterministic and an unmet constraint returns no lock', () => {
|
||||
const base = createRouteBoard(0, { family: 'orbits', radius: 3 });
|
||||
assert.deepEqual(createDeepLock(base, { seed: 5 }), createDeepLock(base, { seed: 5 }));
|
||||
assert.equal(createDeepLock(base, { minHeroBends: 999 }), null);
|
||||
assert.equal(createDeepLock(base, { maxChecks: 0 }), null);
|
||||
});
|
||||
Reference in New Issue
Block a user