mirror of
https://github.com/tiennm99/nntv.git
synced 2026-10-16 02:15:39 +00:00
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:
1 parent
0bf72c50d8
commit
19e3e9cb67
2 files changed
+169
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user