feat(game): add BFS level solver for solvability validation

Pure BFS over (player, guard snapshots, princess state). Reuses guards.js
and princess-mechanic.js so solver verdicts match runtime behavior. State
budget cap prevents runaway search on impossible levels.
This commit is contained in:
tiennm99 committed 2026-04-21 21:20:12 +07:00
1 parent 0bf72c50d8
commit 19e3e9cb67
2 files changed
+169

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
// BFS solver for level solvability validation.
// Replays the real turn pipeline (guards.js + princess-mechanic.js) so solver
// verdicts match runtime behavior exactly — no reimplementation of AI.
//
// Usage:
// const result = solveLevel(levelId, { maxStates: 5_000_000 });
// // { solvable, path?, reason?, states_explored }
import { loadLevel } from './level-manager.js';
import { PrincessMechanic } from './princess-mechanic.js';
const ACTIONS = ['up', 'down', 'left', 'right', 'wait'];
function captureState(player, guards, princess) {
return {
p: { r: player.row, c: player.col },
g: guards.map(x => x.capture()),
pr: princess ? princess.capture() : null,
};
}
function applyState(state, player, guards, princess) {
player.row = state.p.r;
player.col = state.p.c;
guards.forEach((x, i) => x.apply(state.g[i]));
if (princess && state.pr) princess.apply(state.pr);
}
function stateKey(state) {
return JSON.stringify(state);
}
// Simulate one turn exactly as TurnManager.nextTurn does, including princess.
// Returns { detected, levelComplete }. Mutates grid + guards + princess.
function simulateTurn(grid, player, guards, princess, goalRow, goalCol) {
if (grid.isGoal(player.row, player.col)) {
return { detected: false, levelComplete: true };
}
grid.clearAllLight();
guards.forEach(g => g.onTurnChange(guards, player));
if (princess) {
const r = princess.update(grid, player, goalRow, goalCol);
if (r.detected) return { detected: true, levelComplete: false };
}
if (grid.isLight(player.row, player.col)) {
return { detected: true, levelComplete: false };
}
return { detected: false, levelComplete: false };
}
export function solveLevel(levelId, options = {}) {
const maxStates = options.maxStates ?? 5_000_000;
const init = loadLevel(levelId);
if (!init) return { solvable: false, reason: 'invalid_level', states_explored: 0 };
const { grid, player, guards, isFinalLevel, goalRow, goalCol } = init;
const princess = isFinalLevel ? new PrincessMechanic() : null;
if (player.row === goalRow && player.col === goalCol) {
return { solvable: true, path: [], states_explored: 0 };
}
const startState = captureState(player, guards, princess);
const visited = new Set([stateKey(startState)]);
const queue = [{ state: startState, path: [] }];
let explored = 0;
while (queue.length > 0) {
if (explored >= maxStates) {
return { solvable: false, reason: 'budget_exhausted', states_explored: explored };
}
const { state, path } = queue.shift();
explored++;
for (const action of ACTIONS) {
// Restore parent state into shared mutable engine objects
applyState(state, player, guards, princess);
grid.clearAllLight();
guards.forEach(g => g.updateLight(guards));
// Apply player action
let nr = player.row, nc = player.col;
if (action === 'up') nr--;
else if (action === 'down') nr++;
else if (action === 'left') nc--;
else if (action === 'right') nc++;
if (action !== 'wait') {
if (!grid.isValidPosition(nr, nc) || grid.isWall(nr, nc)) continue;
player.row = nr;
player.col = nc;
}
// Run turn (matches TurnManager.nextTurn pipeline)
const result = simulateTurn(grid, player, guards, princess, goalRow, goalCol);
const newPath = path.concat(action);
if (result.levelComplete) {
return { solvable: true, path: newPath, states_explored: explored };
}
if (result.detected) continue;
// Check if player reached goal after guard update (edge case: goal cell became lit but player already there — treated as detection by engine)
// Actually: engine checks goal BEFORE guard update; after player moves onto goal, nextTurn catches it next call. Match that here:
if (grid.isGoal(player.row, player.col) && !grid.isLight(player.row, player.col)) {
return { solvable: true, path: newPath, states_explored: explored };
}
const newState = captureState(player, guards, princess);
const key = stateKey(newState);
if (visited.has(key)) continue;
visited.add(key);
queue.push({ state: newState, path: newPath });
}
}
return { solvable: false, reason: 'no_path', states_explored: explored };
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { solveLevel } from './level-solver.js';
import { LEVELS } from '../levels/levels.js';
import { loadLevel } from './level-manager.js';
describe('level-solver', () => {
it('solves trivial level (L1 has no guards)', () => {
const result = solveLevel(1);
expect(result.solvable).toBe(true);
expect(result.path.length).toBeGreaterThan(0);
});
it('L2 is solvable after Phase 4 redesign', () => {
const result = solveLevel(2);
expect(result.solvable).toBe(true);
expect(result.path.length).toBeGreaterThan(0);
});
it('returns invalid_level for out-of-range IDs', () => {
expect(solveLevel(0).solvable).toBe(false);
expect(solveLevel(99).solvable).toBe(false);
});
it('solved path length is reasonable vs par (L1)', () => {
const result = solveLevel(1);
const par = LEVELS[0].parMoves;
expect(result.path.length).toBeLessThanOrEqual(par);
});
it('solver replay actually works (L1 path reaches goal via simulated play)', () => {
const { path } = solveLevel(1);
const init = loadLevel(1);
const { grid, player } = init;
// Walk the path step by step
for (const action of path) {
let nr = player.row, nc = player.col;
if (action === 'up') nr--;
else if (action === 'down') nr++;
else if (action === 'left') nc--;
else if (action === 'right') nc++;
if (action !== 'wait') {
expect(grid.isValidPosition(nr, nc)).toBe(true);
expect(grid.isWall(nr, nc)).toBe(false);
player.row = nr;
player.col = nc;
}
}
expect(grid.isGoal(player.row, player.col)).toBe(true);
});
});