mirror of
https://github.com/tiennm99/nntv.git
synced 2026-10-16 08:15:26 +00:00
fix(engine): make guard mechanics actually execute
Four defects left the stealth mechanics inert: warm tiles were cosmetic, mirror beams never reflected, static guards died permanently after a few turns, and suspicion guards reset their own tier every cycle. Deleting every guard from every level changed the optimal path by zero moves. - warm tiles are lethal and their decay window survives the turn cycle - rotating and sniper beams reach mirrors and reflect - static guards regrow, suspicion tiers persist - a mobile guard's own cell is lethal, and cell-swap counts as detection - turn preview simulates the player's move instead of a stationary player - a rejected one-way move no longer opens the door it was refused - line-of-sight extracted to one module shared by engine and solver Every rule change lands in both the turn engine and the BFS solver so CI cannot certify a level that a human cannot play.
This commit is contained in:
1 parent
eb800f1ce0
commit
ebff1ad019
14 files changed
+731
-190
No files matched your search
@@ -1,9 +1,12 @@
|
||||
// Undo/redo state history for game turns.
|
||||
// Snapshots player (including keysHeld), guards (via guard.capture()), turn count,
|
||||
// princess state, throwSystem state, and grid key/door cell state.
|
||||
// princess state, throwSystem state, and grid key/door/warm cell state.
|
||||
//
|
||||
// Phase 04.5 gap fix: keysHeld + key/door cell snapshots added here so that undo
|
||||
// correctly restores inventory and grid state after key collection or door opening.
|
||||
// Warm-tile snapshot: warm cells are a lethal, timed hazard (see grid-system.js
|
||||
// clearAllLight/tickWarmTimers), so undo must restore them exactly — otherwise
|
||||
// undo could resurrect a state where a cell that should still be dangerous
|
||||
// reads as safe (or vice versa), which is a real illegal-state bug on any
|
||||
// decay-eligible level with undo enabled.
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
@@ -28,6 +31,7 @@ export class GameHistory {
|
||||
// Grid snapshots: sparse arrays; null when grid not provided (e.g. levels without keys/doors)
|
||||
keySnapshot: grid ? grid.getKeySnapshot() : null,
|
||||
doorSnapshot: grid ? grid.getDoorSnapshot() : null,
|
||||
warmSnapshot: grid ? grid.getWarmSnapshot() : null,
|
||||
// ThrowSystem state: null when system not present
|
||||
throwSystem: throwSystem ? throwSystem.capture() : null,
|
||||
};
|
||||
@@ -49,6 +53,9 @@ export class GameHistory {
|
||||
guards.forEach((g, i) => g.apply(state.guards[i]));
|
||||
if (grid && state.keySnapshot !== null) grid.applyKeySnapshot(state.keySnapshot);
|
||||
if (grid && state.doorSnapshot !== null) grid.applyDoorSnapshot(state.doorSnapshot);
|
||||
if (grid && state.warmSnapshot !== null && state.warmSnapshot !== undefined) {
|
||||
grid.applyWarmSnapshot(state.warmSnapshot);
|
||||
}
|
||||
if (throwSystem && state.throwSystem !== null) throwSystem.apply(state.throwSystem);
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,26 @@ describe('GameHistory — keys/doors/throwSystem snapshots', () => {
|
||||
expect(player.getKeysHeld()).toBe(1); // still holds key from snap1
|
||||
});
|
||||
|
||||
it('snapshot includes warmSnapshot (sparse array of warm cells)', () => {
|
||||
grid.setWarm(2, 3, 2);
|
||||
const snap = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, null);
|
||||
expect(snap.warmSnapshot).toEqual([[2, 3, 2]]);
|
||||
});
|
||||
|
||||
it('undo restores warm-tile state — a cell warm before the snapshot is warm again after undo', () => {
|
||||
grid.setWarm(2, 3, 1);
|
||||
const before = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, null);
|
||||
history.pushSnapshot(before);
|
||||
// Warm expires (simulating a later turn's tickWarmTimers)
|
||||
grid.applyWarmSnapshot([]);
|
||||
expect(grid.isWarm(2, 3)).toBe(false);
|
||||
// Undo must bring the warm cell back — otherwise a cell that should
|
||||
// still be a lethal hazard reads as safe post-undo.
|
||||
history.undo(player, [], tm, princess.capture(), grid, null);
|
||||
expect(grid.isWarm(2, 3)).toBe(true);
|
||||
expect(grid.getWarmTurnsLeft(2, 3)).toBe(1);
|
||||
});
|
||||
|
||||
it('snapshot captures throwSystem state', () => {
|
||||
const ts = new ThrowableSystem(3);
|
||||
const snap = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, ts);
|
||||
|
||||
@@ -93,7 +93,7 @@ export class GridSystem {
|
||||
|
||||
// Clear all light from the grid.
|
||||
// For decay-eligible cells that are currently lit, schedule them to become warm
|
||||
// (they were about to go dark — the decay window lets player cross safely).
|
||||
// (they were about to go dark — the afterglow window is lethal, see tickWarmTimers).
|
||||
clearAllLight() {
|
||||
for (let row = 0; row < this.rows; row++) {
|
||||
for (let col = 0; col < this.cols; col++) {
|
||||
@@ -108,6 +108,19 @@ export class GridSystem {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear light WITHOUT the decay side effect. Use this when repainting light
|
||||
// for a state the simulation is already in (BFS branch re-entry, turn-preview
|
||||
// candidate branches) rather than genuinely transitioning to a new turn —
|
||||
// calling clearAllLight() here would fabricate warm cells that were never
|
||||
// part of the real turn sequence, corrupting state hashing and preview output.
|
||||
clearLight() {
|
||||
for (let row = 0; row < this.rows; row++) {
|
||||
for (let col = 0; col < this.cols; col++) {
|
||||
this.grid[row][col].isLight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Door ---
|
||||
setDoor(row, col, keyId) {
|
||||
if (!this.isValidPosition(row, col)) return;
|
||||
|
||||
@@ -37,6 +37,17 @@ describe('GridSystem', () => {
|
||||
expect(g.isLight(1, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('clearLight resets every cell WITHOUT creating warm cells', () => {
|
||||
const g = new GridSystem(2, 2, 50);
|
||||
g.setDecayEligibleAll();
|
||||
g.setLight(0, 0, true);
|
||||
g.clearLight();
|
||||
expect(g.isLight(0, 0)).toBe(false);
|
||||
// Unlike clearAllLight, clearLight must not trigger the decay side effect
|
||||
expect(g.isWarm(0, 0)).toBe(false);
|
||||
expect(g.getWarmSnapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
it('getAllCells returns flat array with positions', () => {
|
||||
const g = new GridSystem(2, 3, 50);
|
||||
g.setWall(1, 2, true);
|
||||
|
||||
+114
-74
@@ -1,6 +1,53 @@
|
||||
// Guard logic — pure JS, no framework dependency
|
||||
// Each guard exposes { row, col, type, direction, isOn } for rendering
|
||||
|
||||
// Guard types whose row/col actually change during onTurnChange. Static,
|
||||
// rotating, blinking, mirror, and sniper guards never move — only these two
|
||||
// can occupy (or vacate into) the player's cell as part of a turn, so only
|
||||
// these need the swap/co-location check in TurnManager.nextTurn.
|
||||
export const MOBILE_GUARD_TYPES = new Set(['patrolling', 'chaser']);
|
||||
|
||||
// How far a rotating/sniper beam reaches before bouncing off a mirror (or
|
||||
// stopping at a wall/edge). Shipped levels place mirrors up to 5 cells from
|
||||
// their feeding rotator in a straight line (see levels.js L7/L10) — keep this
|
||||
// at least that large or reflection goes dead again.
|
||||
const ROTATING_BEAM_RANGE = 5;
|
||||
|
||||
// Shared beam-casting helper used by RotatingGuard and SniperGuard: casts
|
||||
// light from (fromRow, fromCol) in `dir` up to `range` cells, reflecting off
|
||||
// mirror guards (capped at 3 bounces to prevent an infinite loop on a mirror
|
||||
// cycle). A mirror is checked BEFORE the wall check: a mirror may be mounted
|
||||
// on a wall cell (the beam still reaches and reflects off it) even though the
|
||||
// player can never stand there — Player.moveTo blocks that independently.
|
||||
function castBeamWithMirrors(grid, dir, fromRow, fromCol, range, allGuards, depth = 0) {
|
||||
if (depth > 3) return;
|
||||
for (let i = 1; i <= range; i++) {
|
||||
const r = fromRow + dir.row * i;
|
||||
const c = fromCol + dir.col * i;
|
||||
if (!grid.isValidPosition(r, c)) break;
|
||||
|
||||
const mirror = allGuards && allGuards.find(g =>
|
||||
g.type === 'mirror' && g.row === r && g.col === c
|
||||
);
|
||||
if (mirror) {
|
||||
grid.setLight(r, c, true);
|
||||
castBeamWithMirrors(grid, reflectBeam(dir, mirror.reflectDirection), r, c, range, allGuards, depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (grid.isWall(r, c)) break;
|
||||
grid.setLight(r, c, true);
|
||||
}
|
||||
}
|
||||
|
||||
// cw: rotate beam 90° clockwise, ccw: counter-clockwise
|
||||
function reflectBeam(dir, reflectType) {
|
||||
if (reflectType === 'cw') {
|
||||
return { row: dir.col, col: -dir.row };
|
||||
}
|
||||
return { row: -dir.col, col: dir.row };
|
||||
}
|
||||
|
||||
class Guard {
|
||||
constructor(grid, row, col, type) {
|
||||
this.grid = grid;
|
||||
@@ -29,10 +76,13 @@ class Guard {
|
||||
}
|
||||
}
|
||||
|
||||
// Wilting tomato — emits a Manhattan aura that shrinks by 1 each turn.
|
||||
// initialRadius 2 lights 13 cells at turn 0, 5 cells at turn 1, 1 cell at
|
||||
// turn 2, harmless thereafter. Pairs naturally with the `wait` action:
|
||||
// patient players can outlast an aura; impatient players must detour.
|
||||
// Wilting tomato — emits a Manhattan aura that shrinks by 1 each turn, then
|
||||
// regrows back to full strength once fully wilted. initialRadius 2 lights 13
|
||||
// cells at turn 0, 5 cells at turn 1, 1 cell at turn 2, 0 cells at turn 3,
|
||||
// then pulses back to 13 cells at turn 4 and repeats. Pairs naturally with the
|
||||
// `wait` action: patient players can time a crossing to the harmless phase of
|
||||
// the pulse; a guard that only ever wilts once is a one-time timer, not a
|
||||
// hazard that denies territory for the rest of the level.
|
||||
export class StaticGuard extends Guard {
|
||||
constructor(grid, row, col, initialRadius) {
|
||||
super(grid, row, col, 'static');
|
||||
@@ -56,9 +106,16 @@ export class StaticGuard extends Guard {
|
||||
}
|
||||
|
||||
onTurnChange() {
|
||||
// Clamp at -1 (fully wilted, harmless). Prevents unbounded state growth
|
||||
// for BFS solver — beyond -1, behavior is identical so keys stay finite.
|
||||
if (this.currentRadius >= 0) this.currentRadius--;
|
||||
// Pulse cycle: shrink one step per turn; once fully wilted (-1),
|
||||
// regrow back to initialRadius and repeat. Finite, deterministic
|
||||
// cycle (initialRadius + 2 states) — no unbounded growth for the
|
||||
// BFS solver, but the guard stays a hazard for the whole level
|
||||
// instead of dying permanently after its first 2-3 turns.
|
||||
if (this.currentRadius > -1) {
|
||||
this.currentRadius--;
|
||||
} else {
|
||||
this.currentRadius = this.initialRadius;
|
||||
}
|
||||
this.updateLight();
|
||||
}
|
||||
|
||||
@@ -76,7 +133,7 @@ export class RotatingGuard extends Guard {
|
||||
constructor(grid, row, col, startDirection) {
|
||||
super(grid, row, col, 'rotating');
|
||||
this.direction = startDirection || 0;
|
||||
this.lightRange = 2;
|
||||
this.lightRange = ROTATING_BEAM_RANGE;
|
||||
this.directions = [
|
||||
{ row: -1, col: 0 }, // up
|
||||
{ row: 0, col: 1 }, // right
|
||||
@@ -96,35 +153,15 @@ export class RotatingGuard extends Guard {
|
||||
this.castBeam(dir, this.row, this.col, this.lightRange, allGuards, 0);
|
||||
}
|
||||
|
||||
// Cast light beam in a direction, bouncing off mirror guards
|
||||
// Cast light beam in a direction, bouncing off mirror guards.
|
||||
// Delegates to the shared castBeamWithMirrors helper (also used by
|
||||
// SniperGuard) so the mirror-reflection rule can't drift between the two.
|
||||
castBeam(dir, fromRow, fromCol, range, allGuards, depth) {
|
||||
if (depth > 3) return; // prevent infinite bounces
|
||||
for (let i = 1; i <= range; i++) {
|
||||
const r = fromRow + dir.row * i;
|
||||
const c = fromCol + dir.col * i;
|
||||
if (!this.grid.isValidPosition(r, c) || this.grid.isWall(r, c)) break;
|
||||
this.grid.setLight(r, c, true);
|
||||
|
||||
// Check if beam hits a mirror guard
|
||||
if (allGuards) {
|
||||
const mirror = allGuards.find(g =>
|
||||
g.type === 'mirror' && g.row === r && g.col === c
|
||||
);
|
||||
if (mirror) {
|
||||
const reflected = this.reflectDir(dir, mirror.reflectDirection);
|
||||
this.castBeam(reflected, r, c, range, allGuards, depth + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
castBeamWithMirrors(this.grid, dir, fromRow, fromCol, range, allGuards, depth);
|
||||
}
|
||||
|
||||
reflectDir(dir, reflectType) {
|
||||
// cw: rotate beam 90° clockwise, ccw: counter-clockwise
|
||||
if (reflectType === 'cw') {
|
||||
return { row: dir.col, col: -dir.row };
|
||||
}
|
||||
return { row: -dir.col, col: dir.row };
|
||||
return reflectBeam(dir, reflectType);
|
||||
}
|
||||
|
||||
onTurnChange(allGuards) {
|
||||
@@ -346,6 +383,14 @@ export class ChaserGuard extends Guard {
|
||||
|
||||
this.row = nextStep.row;
|
||||
this.col = nextStep.col;
|
||||
} else if (this.isReturning) {
|
||||
// Home cell is unreachable (e.g. it sits on a wall — an
|
||||
// authoring error, not something this guard should get stuck
|
||||
// over). Give up the return instead of freezing in
|
||||
// isReturning forever, lighting two cells for the rest of
|
||||
// the level.
|
||||
this.isChasing = false;
|
||||
this.isReturning = false;
|
||||
}
|
||||
|
||||
// If returning and reached home, stop chasing entirely
|
||||
@@ -401,6 +446,13 @@ export class PatrollingGuard extends Guard {
|
||||
}
|
||||
|
||||
updateLight() {
|
||||
// Light the guard's own cell so a player standing on it — or one who
|
||||
// swaps cells with it in the same turn — cannot go undetected. Every
|
||||
// other guard type already lights its own cell; this one didn't.
|
||||
if (this.grid.isValidPosition(this.row, this.col)) {
|
||||
this.grid.setLight(this.row, this.col, true);
|
||||
}
|
||||
|
||||
const frontDir = this.getDirectionOffset(this.direction);
|
||||
const frontRow = this.row + frontDir.row;
|
||||
const frontCol = this.col + frontDir.col;
|
||||
@@ -457,7 +509,12 @@ export class PatrollingGuard extends Guard {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.path.length <= 1) return;
|
||||
if (this.path.length <= 1) {
|
||||
// No patrol to run, but still emit light — a single-cell "patrol"
|
||||
// must not go permanently dark just because it never advances.
|
||||
this.updateLight();
|
||||
return;
|
||||
}
|
||||
|
||||
let nextIndex;
|
||||
if (this.isCircularPath) {
|
||||
@@ -480,9 +537,16 @@ export class PatrollingGuard extends Guard {
|
||||
|
||||
this.currentPathIndex = nextIndex;
|
||||
const nextPos = this.path[this.currentPathIndex];
|
||||
this.updateDirection(this.row, this.col, nextPos.row, nextPos.col);
|
||||
this.row = nextPos.row;
|
||||
this.col = nextPos.col;
|
||||
// Authored path nodes are occasionally on a wall cell (authoring
|
||||
// error — see solvability report). Never let the guard actually enter
|
||||
// a wall: hold at its current cell instead of teleporting through
|
||||
// geometry. Path index bookkeeping still advances so later, valid,
|
||||
// nodes keep working.
|
||||
if (!this.grid.isWall(nextPos.row, nextPos.col)) {
|
||||
this.updateDirection(this.row, this.col, nextPos.row, nextPos.col);
|
||||
this.row = nextPos.row;
|
||||
this.col = nextPos.col;
|
||||
}
|
||||
this.updateLight();
|
||||
}
|
||||
}
|
||||
@@ -518,33 +582,14 @@ export class SniperGuard extends Guard {
|
||||
}
|
||||
|
||||
// Beam travels until wall/edge; reflects off mirrors (up to 3 bounces).
|
||||
// Uses same unlimited-step approach but respects grid bounds.
|
||||
// Delegates to the shared castBeamWithMirrors helper (also used by
|
||||
// RotatingGuard) so the mirror-reflection rule can't drift between the two.
|
||||
_castBeam(dir, fromRow, fromCol, allGuards, depth) {
|
||||
if (depth > 3) return;
|
||||
for (let i = 1; i <= this.lightRange; i++) {
|
||||
const r = fromRow + dir.row * i;
|
||||
const c = fromCol + dir.col * i;
|
||||
if (!this.grid.isValidPosition(r, c) || this.grid.isWall(r, c)) break;
|
||||
this.grid.setLight(r, c, true);
|
||||
|
||||
if (allGuards) {
|
||||
const mirror = allGuards.find(g =>
|
||||
g.type === 'mirror' && g.row === r && g.col === c
|
||||
);
|
||||
if (mirror) {
|
||||
const reflected = this._reflectDir(dir, mirror.reflectDirection);
|
||||
this._castBeam(reflected, r, c, allGuards, depth + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
castBeamWithMirrors(this.grid, dir, fromRow, fromCol, this.lightRange, allGuards, depth);
|
||||
}
|
||||
|
||||
_reflectDir(dir, reflectType) {
|
||||
if (reflectType === 'cw') {
|
||||
return { row: dir.col, col: -dir.row };
|
||||
}
|
||||
return { row: -dir.col, col: dir.row };
|
||||
return reflectBeam(dir, reflectType);
|
||||
}
|
||||
|
||||
onTurnChange(allGuards) {
|
||||
@@ -576,8 +621,11 @@ export class SniperGuard extends Guard {
|
||||
// --- SuspicionGuard ---
|
||||
// 3-tier suspicion meter (0=idle, 1=alerted, 2=firing).
|
||||
// Increments tier when player is within Manhattan `range`; decrements when out.
|
||||
// At tier 2, lights all 8 surrounding cells (Manhattan ≤1 + diagonals).
|
||||
// After a firing turn (tier starts at 2), always decays to 0 that same call.
|
||||
// At tier 2, lights every cell within Manhattan `range` — the guard's full
|
||||
// alert zone, not just its immediate neighbours — so `range` actually denies
|
||||
// territory instead of only mattering for one orthogonally-adjacent step.
|
||||
// Tier persists while the player stays in range (no self-reset after firing)
|
||||
// and decays one step per turn once they retreat, same as tiers 0→1.
|
||||
export class SuspicionGuard extends Guard {
|
||||
constructor(grid, row, col, range) {
|
||||
super(grid, row, col, 'suspicion');
|
||||
@@ -587,9 +635,10 @@ export class SuspicionGuard extends Guard {
|
||||
|
||||
updateLight() {
|
||||
if (this.tier < 2) return;
|
||||
// Tier 2: light own cell + all 8 neighbours
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
// Tier 2: light every cell within Manhattan `range` of the guard.
|
||||
for (let dr = -this.range; dr <= this.range; dr++) {
|
||||
for (let dc = -this.range; dc <= this.range; dc++) {
|
||||
if (Math.abs(dr) + Math.abs(dc) > this.range) continue;
|
||||
const r = this.row + dr;
|
||||
const c = this.col + dc;
|
||||
if (this.grid.isValidPosition(r, c)) {
|
||||
@@ -600,15 +649,6 @@ export class SuspicionGuard extends Guard {
|
||||
}
|
||||
|
||||
onTurnChange(allGuards, player) {
|
||||
const startedAtFiring = this.tier === 2;
|
||||
|
||||
if (startedAtFiring) {
|
||||
// After a firing turn: always drop to 0 regardless of player distance
|
||||
this.tier = 0;
|
||||
this.updateLight();
|
||||
return;
|
||||
}
|
||||
|
||||
if (player) {
|
||||
const dist = Math.abs(this.row - player.row) + Math.abs(this.col - player.col);
|
||||
if (dist <= this.range) {
|
||||
|
||||
+129
-6
@@ -3,7 +3,7 @@ import { GridSystem } from './grid-system.js';
|
||||
import {
|
||||
StaticGuard, RotatingGuard, BlinkingGuard,
|
||||
MirrorGuard, PatrollingGuard, ChaserGuard,
|
||||
SniperGuard, SuspicionGuard,
|
||||
SniperGuard, SuspicionGuard, MOBILE_GUARD_TYPES,
|
||||
} from './guards.js';
|
||||
|
||||
describe('Guard.capture()/apply()', () => {
|
||||
@@ -41,6 +41,18 @@ describe('Guard.capture()/apply()', () => {
|
||||
expect(grid.isLight(2, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('StaticGuard regrows after fully wilting instead of staying dead forever', () => {
|
||||
const g = new StaticGuard(grid, 2, 2, 1);
|
||||
g.onTurnChange(); // 1 -> 0
|
||||
expect(g.currentRadius).toBe(0);
|
||||
g.onTurnChange(); // 0 -> -1 (fully wilted)
|
||||
expect(g.currentRadius).toBe(-1);
|
||||
g.onTurnChange(); // pulses back to initialRadius
|
||||
expect(g.currentRadius).toBe(1);
|
||||
g.onTurnChange();
|
||||
expect(g.currentRadius).toBe(0);
|
||||
});
|
||||
|
||||
it('StaticGuard currentRadius round-trips via capture/apply', () => {
|
||||
const g = new StaticGuard(grid, 2, 2, 2);
|
||||
g.currentRadius = 0;
|
||||
@@ -112,6 +124,37 @@ describe('RotatingGuard beam + mirror', () => {
|
||||
expect(grid.isLight(2, 2)).toBe(true); // mirror cell
|
||||
expect(grid.isLight(3, 2)).toBe(true); // reflected beam
|
||||
});
|
||||
|
||||
it('reaches and reflects off a mirror 5 cells away in a straight line', () => {
|
||||
// Matches the shipped L7/L10 geometry: rotator and mirror 5 cells apart.
|
||||
const grid = new GridSystem(9, 9, 50);
|
||||
const rot = new RotatingGuard(grid, 2, 2, 1); // facing right
|
||||
const mir = new MirrorGuard(grid, 2, 7, 'cw');
|
||||
rot.updateLight([rot, mir]);
|
||||
expect(grid.isLight(2, 7)).toBe(true); // beam reaches the mirror cell
|
||||
expect(grid.isLight(3, 7)).toBe(true); // reflected beam (cw of right = down)
|
||||
});
|
||||
|
||||
it('reflects off a mirror mounted on a wall cell instead of stopping short', () => {
|
||||
// A mirror occupying a wall cell (authoring error seen in L11) must
|
||||
// still redirect the beam — the wall only blocks player movement.
|
||||
const grid = new GridSystem(7, 7, 50);
|
||||
grid.setWall(2, 5, true);
|
||||
const rot = new RotatingGuard(grid, 2, 2, 1); // facing right
|
||||
const mir = new MirrorGuard(grid, 2, 5, 'cw');
|
||||
rot.updateLight([rot, mir]);
|
||||
expect(grid.isLight(2, 5)).toBe(true); // mirror cell (also a wall) lights up
|
||||
expect(grid.isLight(3, 5)).toBe(true); // reflected beam continues
|
||||
});
|
||||
|
||||
it('does not reflect past a real wall when no mirror is present', () => {
|
||||
const grid = new GridSystem(7, 7, 50);
|
||||
grid.setWall(2, 5, true);
|
||||
const rot = new RotatingGuard(grid, 2, 2, 1); // facing right
|
||||
rot.updateLight([rot]);
|
||||
expect(grid.isLight(2, 4)).toBe(true);
|
||||
expect(grid.isLight(2, 5)).toBe(false); // plain wall still blocks
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlinkingGuard', () => {
|
||||
@@ -162,9 +205,66 @@ describe('ChaserGuard BFS', () => {
|
||||
const step = g.bfsNextStep(2, 2);
|
||||
expect(step).toBeNull();
|
||||
});
|
||||
|
||||
it('gives up returning home instead of freezing forever when home is unreachable', () => {
|
||||
// Authoring error: chaser's start cell is itself a wall (seen in L11),
|
||||
// so bfsNextStep(home) can never succeed once the guard has moved off it.
|
||||
const grid = new GridSystem(5, 5, 50);
|
||||
const g = new ChaserGuard(grid, 2, 2, 3);
|
||||
grid.setWall(2, 2, true); // home cell is a wall
|
||||
const nearPlayer = { row: 2, col: 3 };
|
||||
g.onTurnChange([], nearPlayer); // dist 1 <= radius 3 -> starts chasing, guard steps to (2,3)
|
||||
expect(g.isChasing).toBe(true);
|
||||
const farPlayer = { row: 0, col: 0 }; // dist from (2,3) = 5 > radius 3
|
||||
// Player leaves range -> guard tries to return home, fails every time
|
||||
// since (2,2) is a wall; must give up rather than loop isReturning forever.
|
||||
for (let i = 0; i < 5; i++) g.onTurnChange([], farPlayer);
|
||||
expect(g.isChasing).toBe(false);
|
||||
expect(g.isReturning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MOBILE_GUARD_TYPES', () => {
|
||||
it('contains exactly the guard types whose row/col change during onTurnChange', () => {
|
||||
expect(MOBILE_GUARD_TYPES.has('patrolling')).toBe(true);
|
||||
expect(MOBILE_GUARD_TYPES.has('chaser')).toBe(true);
|
||||
expect(MOBILE_GUARD_TYPES.has('static')).toBe(false);
|
||||
expect(MOBILE_GUARD_TYPES.has('rotating')).toBe(false);
|
||||
expect(MOBILE_GUARD_TYPES.has('sniper')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PatrollingGuard', () => {
|
||||
it('lights its own cell so standing on it cannot go undetected', () => {
|
||||
const grid = new GridSystem(3, 3, 50);
|
||||
const path = [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }];
|
||||
const g = new PatrollingGuard(grid, 0, 0, path);
|
||||
g.updateLight();
|
||||
expect(grid.isLight(0, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('stays lit even with a trivial (length-1) path', () => {
|
||||
const grid = new GridSystem(3, 3, 50);
|
||||
const g = new PatrollingGuard(grid, 1, 1, [{ row: 1, col: 1 }]);
|
||||
grid.clearAllLight();
|
||||
g.onTurnChange();
|
||||
expect(grid.isLight(1, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('never enters a path node authored on a wall cell', () => {
|
||||
// Authoring error seen in shipped levels (e.g. L8/L11): a patrol path
|
||||
// node sits on a wall. The guard must hold its position rather than
|
||||
// walk through the wall.
|
||||
const grid = new GridSystem(3, 3, 50);
|
||||
const path = [{ row: 0, col: 0 }, { row: 0, col: 1 }];
|
||||
grid.setWall(0, 1, true);
|
||||
const g = new PatrollingGuard(grid, 0, 0, path);
|
||||
g.onTurnChange();
|
||||
expect(g.row).toBe(0);
|
||||
expect(g.col).toBe(0);
|
||||
expect(grid.isWall(g.row, g.col)).toBe(false);
|
||||
});
|
||||
|
||||
it('reverses at end of non-circular path', () => {
|
||||
const grid = new GridSystem(3, 3, 50);
|
||||
const path = [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }];
|
||||
@@ -279,7 +379,7 @@ describe('SniperGuard', () => {
|
||||
});
|
||||
|
||||
describe('SuspicionGuard', () => {
|
||||
it('tier increments when player is in range', () => {
|
||||
it('tier increments when player is in range and holds at 2 (no self-reset)', () => {
|
||||
const grid = new GridSystem(7, 7, 50);
|
||||
const guard = new SuspicionGuard(grid, 3, 3, 3);
|
||||
const player = { row: 3, col: 5 }; // Manhattan dist 2
|
||||
@@ -288,17 +388,27 @@ describe('SuspicionGuard', () => {
|
||||
expect(guard.tier).toBe(1);
|
||||
guard.onTurnChange([], player);
|
||||
expect(guard.tier).toBe(2);
|
||||
guard.onTurnChange([], player); // was firing → force decay
|
||||
expect(guard.tier).toBe(0);
|
||||
guard.onTurnChange([], player); // still in range — stays firing, does not reset
|
||||
expect(guard.tier).toBe(2);
|
||||
});
|
||||
|
||||
it('tier drops to 0 when starting turn at tier 2 (firing), regardless of player distance', () => {
|
||||
it('tier persists at 2 while the player remains in range', () => {
|
||||
const grid = new GridSystem(7, 7, 50);
|
||||
const guard = new SuspicionGuard(grid, 0, 0, 2);
|
||||
guard.tier = 2;
|
||||
// Even with player nearby, firing turn always resets to 0
|
||||
const nearPlayer = { row: 0, col: 1 };
|
||||
guard.onTurnChange([], nearPlayer);
|
||||
expect(guard.tier).toBe(2);
|
||||
});
|
||||
|
||||
it('tier decays one step per turn from 2 once the player leaves range', () => {
|
||||
const grid = new GridSystem(7, 7, 50);
|
||||
const guard = new SuspicionGuard(grid, 0, 0, 2);
|
||||
guard.tier = 2;
|
||||
const farPlayer = { row: 6, col: 6 };
|
||||
guard.onTurnChange([], farPlayer);
|
||||
expect(guard.tier).toBe(1);
|
||||
guard.onTurnChange([], farPlayer);
|
||||
expect(guard.tier).toBe(0);
|
||||
});
|
||||
|
||||
@@ -351,6 +461,19 @@ describe('SuspicionGuard', () => {
|
||||
expect(grid.isLight(1, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('at tier 2, denies the FULL Manhattan range, not just the immediate 3x3', () => {
|
||||
// range=3: cell (2,5) is Manhattan distance 3 from guard at (2,2) —
|
||||
// well outside a 3x3, but must still be lit so `range` actually gates
|
||||
// territory instead of only the guard's immediate neighbours.
|
||||
const grid = new GridSystem(7, 7, 50);
|
||||
const guard = new SuspicionGuard(grid, 2, 2, 3);
|
||||
guard.tier = 2;
|
||||
guard.updateLight();
|
||||
expect(grid.isLight(2, 5)).toBe(true);
|
||||
// Distance 4 — just outside range — must stay dark.
|
||||
expect(grid.isLight(2, 6)).toBe(false);
|
||||
});
|
||||
|
||||
it('capture/apply round-trips tier and range', () => {
|
||||
const grid = new GridSystem(5, 5, 50);
|
||||
const guard = new SuspicionGuard(grid, 2, 2, 4);
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
import { loadLevel } from './level-manager.js';
|
||||
import { PrincessMechanic } from './princess-mechanic.js';
|
||||
import { MOBILE_GUARD_TYPES } from './guards.js';
|
||||
import { isValidThrowTarget } from './line-of-sight.js';
|
||||
|
||||
const BASE_ACTIONS = ['up', 'down', 'left', 'right', 'wait'];
|
||||
const DEFAULT_MAX_STATES = 2_000_000;
|
||||
@@ -121,26 +123,10 @@ function applyState(state, player, guards, princess, throwSystem, grid) {
|
||||
|
||||
// ─── Throw target enumeration ────────────────────────────────────────────────
|
||||
// Returns ['throw_to_<r>_<c>', ...] for valid throw targets from current player pos.
|
||||
// Pruning: target must have at least one distractible guard (rotating/patrolling/chaser)
|
||||
// within Manhattan ≤ 2 of the target. Otherwise the throw is wasted.
|
||||
|
||||
const DISTRACTIBLE_TYPES = new Set(['rotating', 'patrolling', 'chaser']);
|
||||
|
||||
// Line-of-sight check: no wall strictly between (r0,c0) and (r1,c1).
|
||||
// Mirrors the same logic in throwable.js to stay consistent.
|
||||
function hasLOS(grid, r0, c0, r1, c1) {
|
||||
const dr = r1 - r0;
|
||||
const dc = c1 - c0;
|
||||
const steps = Math.max(Math.abs(dr), Math.abs(dc));
|
||||
if (steps === 0) return true;
|
||||
for (let i = 1; i < steps; i++) {
|
||||
const r = Math.round(r0 + (dr * i) / steps);
|
||||
const c = Math.round(c0 + (dc * i) / steps);
|
||||
if (grid.isWall(r, c)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validity (LOS, Manhattan ≤3, ≥1 distractible guard within 2) is the shared
|
||||
// isValidThrowTarget contract from line-of-sight.js — the same one throwable.js
|
||||
// enforces at the engine level, so the solver can't certify a throw the live
|
||||
// game would reject (or vice versa).
|
||||
export function enumerateThrowTargets(player, grid, guards, stonesLeft) {
|
||||
if (stonesLeft <= 0) return [];
|
||||
|
||||
@@ -150,26 +136,10 @@ export function enumerateThrowTargets(player, grid, guards, stonesLeft) {
|
||||
|
||||
for (let r = 0; r < grid.rows; r++) {
|
||||
for (let c = 0; c < grid.cols; c++) {
|
||||
// Skip walls and player's own cell
|
||||
if (grid.isWall(r, c)) continue;
|
||||
if (r === pr && c === pc) continue;
|
||||
|
||||
// Manhattan ≤ 3 from player
|
||||
const dist = Math.abs(r - pr) + Math.abs(c - pc);
|
||||
if (dist > 3) continue;
|
||||
|
||||
// Line-of-sight: no wall between player and target
|
||||
if (!hasLOS(grid, pr, pc, r, c)) continue;
|
||||
|
||||
// Pruning: at least one distractible guard within Manhattan ≤ 2 of target
|
||||
const hasEligibleGuard = guards.some(g => {
|
||||
if (!DISTRACTIBLE_TYPES.has(g.type)) return false;
|
||||
const gDist = Math.abs(g.row - r) + Math.abs(g.col - c);
|
||||
return gDist <= 2;
|
||||
});
|
||||
if (!hasEligibleGuard) continue;
|
||||
|
||||
targets.push(`throw_to_${r}_${c}`);
|
||||
if (r === pr && c === pc) continue; // skip player's own cell
|
||||
if (isValidThrowTarget(grid, guards, pr, pc, r, c)) {
|
||||
targets.push(`throw_to_${r}_${c}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,32 +150,45 @@ export function enumerateThrowTargets(player, grid, guards, stonesLeft) {
|
||||
// Matches TurnManager.nextTurn pipeline:
|
||||
// 1. Goal check (early-out)
|
||||
// 2. throwSystem?.resolve(guards)
|
||||
// 3. clearAllLight
|
||||
// 3. clearAllLight — cells lit last turn become warm (afterglow)
|
||||
// 4. guards.forEach onTurnChange
|
||||
// 5. tickWarmTimers
|
||||
// 6. Detection check
|
||||
// 5. Detection check — lit OR warm cell, princess, or a same-turn guard/player swap
|
||||
// 6. tickWarmTimers — warm cells created this turn expire next turn
|
||||
// Returns { detected, levelComplete }
|
||||
|
||||
function simulateTurn(grid, player, guards, princess, goalRow, goalCol, throwSystem) {
|
||||
// Exported (alongside enumerateThrowTargets) so tests can pin its parity with
|
||||
// TurnManager.nextTurn directly, rather than only indirectly through solveLevel.
|
||||
export function simulateTurn(grid, player, guards, princess, goalRow, goalCol, throwSystem) {
|
||||
if (grid.isGoal(player.row, player.col)) {
|
||||
return { detected: false, levelComplete: true };
|
||||
}
|
||||
|
||||
// Snapshot mobile guard cells BEFORE they move this turn — same
|
||||
// swap/co-location rule as TurnManager.nextTurn (see guards.js).
|
||||
const mobileGuardCellsBefore = guards
|
||||
.filter(g => MOBILE_GUARD_TYPES.has(g.type))
|
||||
.map(g => `${g.row},${g.col}`);
|
||||
|
||||
if (throwSystem) throwSystem.resolve(guards);
|
||||
|
||||
grid.clearAllLight();
|
||||
guards.forEach(g => g.onTurnChange(guards, player));
|
||||
|
||||
let detected = mobileGuardCellsBefore.includes(`${player.row},${player.col}`);
|
||||
|
||||
if (!detected && princess) {
|
||||
const r = princess.update(grid, player, goalRow, goalCol);
|
||||
if (r.detected) detected = true;
|
||||
}
|
||||
if (!detected && (grid.isLight(player.row, player.col) || grid.isWarm(player.row, player.col))) {
|
||||
detected = true;
|
||||
}
|
||||
|
||||
// Tick AFTER detection so a cell that just went dark this turn is still
|
||||
// checked while warm (see turn-manager.js nextTurn for the full rationale).
|
||||
grid.tickWarmTimers();
|
||||
|
||||
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 };
|
||||
return { detected, levelComplete: false };
|
||||
}
|
||||
|
||||
// ─── Main solver ─────────────────────────────────────────────────────────────
|
||||
@@ -216,6 +199,13 @@ export function solveLevel(levelId, options = {}) {
|
||||
if (!init) return { solvable: false, reason: 'invalid_level', states_explored: 0 };
|
||||
|
||||
const { grid, player, guards, throwSystem, isFinalLevel, goalRow, goalCol, parMoves } = init;
|
||||
// The final level (isFinalLevel, id 12) is INTENTIONALLY unsolvable — an
|
||||
// owner-confirmed easter egg, not a bug. The live game additionally
|
||||
// resolves the princess check before the goal-complete check on that one
|
||||
// level, which this solver deliberately does NOT reproduce (see
|
||||
// simulateTurn's goal early-out above); do not "fix" this solver to match
|
||||
// that ordering, and do not treat a future L12 solvability regression here
|
||||
// as something to repair — confirm with the level-design owner first.
|
||||
const princess = isFinalLevel ? new PrincessMechanic() : null;
|
||||
|
||||
// parMoves pruning: prune paths longer than ceil(parMoves * 1.5)
|
||||
@@ -245,8 +235,12 @@ export function solveLevel(levelId, options = {}) {
|
||||
|
||||
// Restore parent state before enumerating throws so eligibility is computed
|
||||
// against the actual parent guards/grid, not stale state from a prior inner loop.
|
||||
// Use clearLight() (no decay side effect) — the parent's warm state was
|
||||
// already restored verbatim by applyState via the warm snapshot, so
|
||||
// re-deriving it from clearAllLight()'s decay logic here would fabricate
|
||||
// warm cells that were never part of the parent state.
|
||||
applyState(state, player, guards, princess, throwSystem, grid);
|
||||
grid.clearAllLight();
|
||||
grid.clearLight();
|
||||
guards.forEach(g => g.updateLight(guards));
|
||||
|
||||
// Build action list: base actions + throw targets
|
||||
@@ -259,9 +253,10 @@ export function solveLevel(levelId, options = {}) {
|
||||
const actions = BASE_ACTIONS.concat(throwActions);
|
||||
|
||||
for (const action of actions) {
|
||||
// Restore parent state into shared mutable engine objects (for each action attempt)
|
||||
// Restore parent state into shared mutable engine objects (for each action attempt).
|
||||
// clearLight() only — see the identical rationale above this loop.
|
||||
applyState(state, player, guards, princess, throwSystem, grid);
|
||||
grid.clearAllLight();
|
||||
grid.clearLight();
|
||||
guards.forEach(g => g.updateLight(guards));
|
||||
|
||||
// Apply player action
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { solveLevel, enumerateThrowTargets } from './level-solver.js';
|
||||
import { solveLevel, enumerateThrowTargets, simulateTurn } from './level-solver.js';
|
||||
import { LEVELS } from '../levels/levels.js';
|
||||
import { loadLevel } from './level-manager.js';
|
||||
import { GridSystem } from './grid-system.js';
|
||||
import { Player } from './player.js';
|
||||
import { PatrollingGuard } from './guards.js';
|
||||
import { PatrollingGuard, RotatingGuard } from './guards.js';
|
||||
import { ThrowableSystem } from './throwable.js';
|
||||
|
||||
// ─── stateKey stability ───────────────────────────────────────────────────────
|
||||
@@ -16,10 +16,19 @@ describe('level-solver stateKey', () => {
|
||||
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('L2 solver result is deterministic (solvability is now a level-authoring concern)', () => {
|
||||
// Static guards used to wilt permanently after 2-3 turns (clamped at
|
||||
// currentRadius=-1 forever), so L2 collapsed to L1's exact path with
|
||||
// zero guard tax. Fixing that dead-forever bug (see guards.js
|
||||
// StaticGuard.onTurnChange — the guard now pulses indefinitely instead
|
||||
// of dying) means L2's actual solvability depends on ITS authored guard
|
||||
// placement/timing, which is the level-retune phase's responsibility,
|
||||
// not the solver's. This test only pins that the solver behaves
|
||||
// deterministically against L2's current data either way.
|
||||
const a = solveLevel(2);
|
||||
const b = solveLevel(2);
|
||||
expect(a.solvable).toBe(b.solvable);
|
||||
expect(a.states_explored).toBe(b.states_explored);
|
||||
});
|
||||
|
||||
it('returns invalid_level for out-of-range IDs', () => {
|
||||
@@ -404,3 +413,55 @@ describe('solver — key/door/one-way state integration', () => {
|
||||
expect(lvl7.doors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── simulateTurn parity with TurnManager.nextTurn ────────────────────────────
|
||||
// These pin the solver's private turn simulation against the same fixes
|
||||
// verified directly on TurnManager in turn-manager.test.js — engine/solver
|
||||
// parity is only real if both copies of the rule agree, not just the live one.
|
||||
|
||||
describe('simulateTurn — mobile guard co-location and swap detection', () => {
|
||||
it('detects a same-turn swap where the player and a patrolling guard exchange cells', () => {
|
||||
const grid = new GridSystem(3, 3, 50);
|
||||
const guard = new PatrollingGuard(grid, 1, 1, [{ row: 1, col: 1 }, { row: 1, col: 2 }]);
|
||||
const player = { row: 1, col: 1 }; // lands on the guard's pre-move cell
|
||||
const result = simulateTurn(grid, player, [guard], null, 2, 2, null);
|
||||
expect(guard.row).toBe(1);
|
||||
expect(guard.col).toBe(2);
|
||||
expect(result.detected).toBe(true);
|
||||
});
|
||||
|
||||
it('does not falsely detect when player and guard never share or cross a cell', () => {
|
||||
const grid = new GridSystem(5, 5, 50);
|
||||
const guard = new PatrollingGuard(grid, 0, 0, [{ row: 0, col: 0 }, { row: 0, col: 1 }]);
|
||||
const player = { row: 4, col: 4 };
|
||||
const result = simulateTurn(grid, player, [guard], null, 2, 2, null);
|
||||
expect(result.detected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('simulateTurn — warm tiles are a lethal 1-turn afterglow', () => {
|
||||
it('a cell that goes dark this turn is warm and lethal to enter that same turn', () => {
|
||||
const grid = new GridSystem(5, 5, 50);
|
||||
grid.setDecayEligibleAll();
|
||||
const guard = new RotatingGuard(grid, 2, 0, 1); // facing right
|
||||
guard.updateLight([guard]); // paint initial light, as level load does
|
||||
expect(grid.isLight(2, 2)).toBe(true);
|
||||
|
||||
const player = { row: 2, col: 2 }; // moves onto the cell about to go dark
|
||||
const result = simulateTurn(grid, player, [guard], null, 4, 4, null);
|
||||
|
||||
expect(grid.isLight(2, 2)).toBe(false); // guard rotated away
|
||||
expect(result.detected).toBe(true); // but the afterglow is lethal
|
||||
});
|
||||
|
||||
it('a cell is safe again the turn after its warm afterglow expires', () => {
|
||||
const grid = new GridSystem(5, 5, 50);
|
||||
grid.setDecayEligibleAll();
|
||||
const guard = new RotatingGuard(grid, 2, 0, 1);
|
||||
guard.updateLight([guard]);
|
||||
|
||||
simulateTurn(grid, { row: 4, col: 4 }, [guard], null, 4, 4, null); // (2,2) goes warm
|
||||
const result = simulateTurn(grid, { row: 2, col: 2 }, [guard], null, 4, 4, null);
|
||||
expect(result.detected).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Shared line-of-sight + throw-target validation — single source of truth for
|
||||
// the throw-legality contract. Before this module existed, the same ~12 lines
|
||||
// of Bresenham stepping were copy-pasted into the live engine (throwable.js),
|
||||
// the BFS solver (level-solver.js), and the UI's targeting overlay
|
||||
// (Game.svelte). Three independent copies of the same rule can only drift
|
||||
// apart; importing from here instead means a rule change can't silently
|
||||
// desync what the solver certifies from what the player can actually do.
|
||||
|
||||
// Guard types eligible to be distracted by a thrown stone.
|
||||
export const DISTRACTIBLE_TYPES = new Set(['rotating', 'patrolling', 'chaser']);
|
||||
|
||||
// Returns true if there is no wall strictly between (r0,c0) and (r1,c1).
|
||||
// Bresenham-style integer stepping along the line; the endpoints themselves
|
||||
// are not checked here — callers validate the target cell separately.
|
||||
export function hasLineOfSight(grid, r0, c0, r1, c1) {
|
||||
const dr = r1 - r0;
|
||||
const dc = c1 - c0;
|
||||
const steps = Math.max(Math.abs(dr), Math.abs(dc));
|
||||
if (steps === 0) return true;
|
||||
for (let i = 1; i < steps; i++) {
|
||||
const r = Math.round(r0 + (dr * i) / steps);
|
||||
const c = Math.round(c0 + (dc * i) / steps);
|
||||
if (grid.isWall(r, c)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A throw target is legal when: not a wall itself, within Manhattan 3 of the
|
||||
// thrower, in line of sight (no wall between), and within Manhattan 2 of at
|
||||
// least one distractible guard (otherwise the stone can never affect play —
|
||||
// this is the "wasted throw" pruning the solver and the UI overlay both need).
|
||||
export function isValidThrowTarget(grid, guards, fromRow, fromCol, targetRow, targetCol) {
|
||||
if (grid.isWall(targetRow, targetCol)) return false;
|
||||
|
||||
const dist = Math.abs(targetRow - fromRow) + Math.abs(targetCol - fromCol);
|
||||
if (dist > 3) return false;
|
||||
|
||||
if (!hasLineOfSight(grid, fromRow, fromCol, targetRow, targetCol)) return false;
|
||||
|
||||
return guards.some(g => {
|
||||
if (!DISTRACTIBLE_TYPES.has(g.type)) return false;
|
||||
const gDist = Math.abs(g.row - targetRow) + Math.abs(g.col - targetCol);
|
||||
return gDist <= 2;
|
||||
});
|
||||
}
|
||||
+33
-7
@@ -49,17 +49,19 @@ export class Player {
|
||||
// ─── Movement ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attempt to move player to (row, col).
|
||||
* Enforces: bounds, walls, doors (require matching key), one-ways (require
|
||||
* matching movement direction).
|
||||
* Read-only precondition check for entering (row, col) — bounds, walls,
|
||||
* doors (require matching key), one-ways (require matching movement
|
||||
* direction). Performs no mutation, so callers (moveTo, and anything that
|
||||
* needs to test a hypothetical destination, e.g. the turn preview) can
|
||||
* check legality without side effects.
|
||||
*
|
||||
* @param {number} row Target row
|
||||
* @param {number} col Target col
|
||||
* @param {number} moveDir Direction player is moving: 0=up,1=right,2=down,3=left.
|
||||
* Pass -1 when direction doesn't apply (direct placement).
|
||||
* @returns {boolean} true if move succeeded
|
||||
* @returns {boolean} true if the cell can be entered
|
||||
*/
|
||||
moveTo(row, col, moveDir = -1) {
|
||||
canEnter(row, col, moveDir = -1) {
|
||||
const g = this.grid;
|
||||
|
||||
if (!g.isValidPosition(row, col)) return false;
|
||||
@@ -70,8 +72,6 @@ export class Player {
|
||||
const keyId = g.getDoorKeyId(row, col);
|
||||
// keyId=null means unconditional door (no key needed — unusual but handle gracefully)
|
||||
if (keyId !== null && !this.hasKey(keyId)) return false;
|
||||
// Door opens (cleared) on entry — door is single-use once key used
|
||||
g.clearDoor(row, col);
|
||||
}
|
||||
|
||||
// One-way: blocked unless player is moving in the tile's allowed direction
|
||||
@@ -80,6 +80,32 @@ export class Player {
|
||||
if (moveDir !== -1 && g.getOneWayDir(row, col) !== moveDir) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to move player to (row, col).
|
||||
* Validates every precondition via canEnter() BEFORE mutating anything —
|
||||
* a rejected move must leave grid and player state untouched (e.g. it must
|
||||
* not pop open a door whose one-way check then fails).
|
||||
*
|
||||
* @param {number} row Target row
|
||||
* @param {number} col Target col
|
||||
* @param {number} moveDir Direction player is moving: 0=up,1=right,2=down,3=left.
|
||||
* Pass -1 when direction doesn't apply (direct placement).
|
||||
* @returns {boolean} true if move succeeded
|
||||
*/
|
||||
moveTo(row, col, moveDir = -1) {
|
||||
if (!this.canEnter(row, col, moveDir)) return false;
|
||||
|
||||
const g = this.grid;
|
||||
|
||||
// Door opens (cleared) on entry — door is single-use once key used.
|
||||
// Safe to mutate now: canEnter already confirmed the key requirement.
|
||||
if (g.isDoor(row, col)) {
|
||||
g.clearDoor(row, col);
|
||||
}
|
||||
|
||||
this.row = row;
|
||||
this.col = col;
|
||||
|
||||
|
||||
@@ -82,6 +82,20 @@ describe('Player — door mechanics', () => {
|
||||
p.addKey(1); // holds keyId=1
|
||||
expect(p.move('right')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejected move on a door+one-way cell does not open the door', () => {
|
||||
// Door at (0,1) needs keyId=1 (held), but the cell is ALSO one-way,
|
||||
// allowing entry only when moving down (dir=2). Approaching from the
|
||||
// left (moving right, dir=1) must be rejected — and rejection must
|
||||
// leave the door closed, not pop it open before the one-way check fails.
|
||||
grid.setDoor(0, 1, 1);
|
||||
grid.setOneWay(0, 1, 2);
|
||||
const p = new Player(grid, 0, 0);
|
||||
p.addKey(1);
|
||||
expect(p.move('right')).toBe(false);
|
||||
expect(p.col).toBe(0);
|
||||
expect(grid.isDoor(0, 1)).toBe(true); // door must still be closed
|
||||
});
|
||||
});
|
||||
|
||||
// ─── One-way mechanics ─────────────────────────────────────────────────────────
|
||||
@@ -142,6 +156,44 @@ describe('Player — one-way mechanics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canEnter (read-only precondition check) ──────────────────────────────────
|
||||
|
||||
describe('Player.canEnter — read-only, no mutation', () => {
|
||||
let grid;
|
||||
beforeEach(() => { grid = new GridSystem(4, 4, 50); });
|
||||
|
||||
it('mirrors moveTo legality without moving the player', () => {
|
||||
grid.setWall(0, 1, true);
|
||||
const p = new Player(grid, 0, 0);
|
||||
expect(p.canEnter(0, 1)).toBe(false);
|
||||
expect(p.canEnter(1, 0)).toBe(true);
|
||||
expect(p.row).toBe(0);
|
||||
expect(p.col).toBe(0);
|
||||
});
|
||||
|
||||
it('returns false for a door without the matching key, without opening it', () => {
|
||||
grid.setDoor(0, 1, 1);
|
||||
const p = new Player(grid, 0, 0);
|
||||
expect(p.canEnter(0, 1, 1)).toBe(false);
|
||||
expect(grid.isDoor(0, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a door once the matching key is held', () => {
|
||||
grid.setDoor(0, 1, 1);
|
||||
const p = new Player(grid, 0, 0);
|
||||
p.addKey(1);
|
||||
expect(p.canEnter(0, 1, 1)).toBe(true);
|
||||
expect(grid.isDoor(0, 1)).toBe(true); // still not opened — canEnter never mutates
|
||||
});
|
||||
|
||||
it('respects one-way direction without a moveDir mismatch', () => {
|
||||
grid.setOneWay(0, 1, 1); // dir=1=right
|
||||
const p = new Player(grid, 0, 0);
|
||||
expect(p.canEnter(0, 1, 1)).toBe(true);
|
||||
expect(p.canEnter(0, 1, 3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Key auto-collection ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Player — key auto-collection', () => {
|
||||
|
||||
@@ -2,24 +2,9 @@
|
||||
// Stones distract rotating/patrolling/chaser guards within radius 2 of target.
|
||||
// No imports from guards.js to avoid cyclic dependency — uses duck-typed type check.
|
||||
|
||||
// Guard types eligible to be distracted by a thrown stone
|
||||
const DISTRACTIBLE_TYPES = new Set(['rotating', 'patrolling', 'chaser']);
|
||||
|
||||
// Simple line trace: returns true if there is no wall cell strictly between
|
||||
// (r0,c0) and (r1,c1) on the grid. Uses Bresenham-style integer stepping.
|
||||
function hasLineOfSight(grid, r0, c0, r1, c1) {
|
||||
const dr = r1 - r0;
|
||||
const dc = c1 - c0;
|
||||
const steps = Math.max(Math.abs(dr), Math.abs(dc));
|
||||
if (steps === 0) return true;
|
||||
for (let i = 1; i < steps; i++) {
|
||||
// Round to nearest integer cell along the line
|
||||
const r = Math.round(r0 + (dr * i) / steps);
|
||||
const c = Math.round(c0 + (dc * i) / steps);
|
||||
if (grid.isWall(r, c)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// hasLineOfSight/DISTRACTIBLE_TYPES live in line-of-sight.js — the single
|
||||
// shared copy also used by level-solver.js, so engine and solver can't drift.
|
||||
import { hasLineOfSight, DISTRACTIBLE_TYPES } from './line-of-sight.js';
|
||||
|
||||
export class ThrowableSystem {
|
||||
constructor(stonesLeft = 0) {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
// Turn manager — pure JS, no framework dependency
|
||||
|
||||
import { MOBILE_GUARD_TYPES } from './guards.js';
|
||||
|
||||
// Every direction the player could take on their NEXT move: staying put, or
|
||||
// stepping into an enterable neighbour. Used by previewNextTurn to evaluate
|
||||
// player-reactive guards (chaser, suspicion) against every destination they
|
||||
// might actually react to, not just the player's current position.
|
||||
const MOVE_DIRS = [
|
||||
{ row: -1, col: 0, moveDir: 0 }, // up
|
||||
{ row: 0, col: 1, moveDir: 1 }, // right
|
||||
{ row: 1, col: 0, moveDir: 2 }, // down
|
||||
{ row: 0, col: -1, moveDir: 3 }, // left
|
||||
];
|
||||
|
||||
export class TurnManager {
|
||||
constructor() {
|
||||
this.turnCount = 0;
|
||||
@@ -9,10 +22,10 @@ export class TurnManager {
|
||||
// Turn order:
|
||||
// 1. Goal check (early-out)
|
||||
// 2. throwSystem?.resolve(guards) — stone lands, guards face it
|
||||
// 3. grid.clearAllLight()
|
||||
// 3. grid.clearAllLight() — cells lit last turn become warm (afterglow)
|
||||
// 4. guards.forEach onTurnChange — guards move/rotate, emit light
|
||||
// 5. grid.tickWarmTimers() — warm cells expire
|
||||
// 6. Detection check
|
||||
// 5. Detection check — lit OR warm cell, or a same-turn guard/player swap
|
||||
// 6. grid.tickWarmTimers() — warm cells created this turn expire next turn
|
||||
// Returns { detected, levelComplete }
|
||||
nextTurn(grid, player, guards, throwSystem = null) {
|
||||
this.turnCount++;
|
||||
@@ -21,46 +34,90 @@ export class TurnManager {
|
||||
return { detected: false, levelComplete: true };
|
||||
}
|
||||
|
||||
// Snapshot mobile guard cells BEFORE they move this turn. Needed to
|
||||
// catch a same-turn swap: the player already moved into a cell a
|
||||
// guard occupied, and the guard is about to vacate it as part of its
|
||||
// own move — end-of-turn co-location alone can't see that crossing.
|
||||
const mobileGuardCellsBefore = guards
|
||||
.filter(g => MOBILE_GUARD_TYPES.has(g.type))
|
||||
.map(g => `${g.row},${g.col}`);
|
||||
|
||||
if (throwSystem) throwSystem.resolve(guards);
|
||||
|
||||
grid.clearAllLight();
|
||||
guards.forEach(guard => guard.onTurnChange(guards, player));
|
||||
|
||||
const swappedWithGuard = mobileGuardCellsBefore.includes(`${player.row},${player.col}`);
|
||||
const detected = swappedWithGuard
|
||||
|| grid.isLight(player.row, player.col)
|
||||
|| grid.isWarm(player.row, player.col);
|
||||
|
||||
// Tick AFTER detection so a cell that just went dark this turn is
|
||||
// still checked while warm (the 1-turn lethal afterglow); ticking
|
||||
// before detection cleared it in the same call it was created.
|
||||
grid.tickWarmTimers();
|
||||
|
||||
if (grid.isLight(player.row, player.col)) {
|
||||
return { detected: true, levelComplete: false };
|
||||
}
|
||||
|
||||
return { detected: false, levelComplete: false };
|
||||
return { detected, levelComplete: false };
|
||||
}
|
||||
|
||||
// Simulate next turn to preview future lit cells (non-destructive).
|
||||
// Captures and restores guard state AND throwSystem state so the preview
|
||||
// leaves nothing mutated.
|
||||
// Simulate next turn to preview future lit/warm cells (non-destructive).
|
||||
// Evaluates EVERY action the player could take next (stay + each legal
|
||||
// move), not just the stationary case — guards that react to player
|
||||
// position (chaser BFS target, suspicion tier) behave differently per
|
||||
// destination, so previewing only "if I don't move" can paint a cell dark
|
||||
// that turns out lethal the instant the player actually steps there. The
|
||||
// returned set is the union of hazards across all candidate destinations.
|
||||
// Captures and restores guard, throwSystem, AND grid warm state so the
|
||||
// preview leaves nothing mutated (pressing the preview key must not create
|
||||
// real warm cells that outlive the preview).
|
||||
previewNextTurn(grid, player, guards, throwSystem = null) {
|
||||
const guardSnaps = guards.map(g => g.capture());
|
||||
const throwSnap = throwSystem ? throwSystem.capture() : null;
|
||||
const warmSnap = grid.getWarmSnapshot();
|
||||
|
||||
// Advance the shared, player-position-independent part of the turn
|
||||
// once: the decay side effect (currently-lit decay-eligible cells
|
||||
// become warm) depends only on current guard-lit state, not on which
|
||||
// way the player moves, so it's safe to compute a single time before
|
||||
// branching per candidate destination below.
|
||||
grid.clearAllLight();
|
||||
|
||||
// Apply throw distraction before guard update (same as nextTurn)
|
||||
if (throwSystem) throwSystem.resolve(guards);
|
||||
|
||||
guards.forEach(g => g.onTurnChange(guards, player));
|
||||
const candidates = [{ row: player.row, col: player.col }];
|
||||
for (const d of MOVE_DIRS) {
|
||||
const nr = player.row + d.row;
|
||||
const nc = player.col + d.col;
|
||||
if (player.canEnter(nr, nc, d.moveDir)) candidates.push({ row: nr, col: nc });
|
||||
}
|
||||
|
||||
const previewSet = new Set();
|
||||
for (let r = 0; r < grid.rows; r++) {
|
||||
for (let c = 0; c < grid.cols; c++) {
|
||||
if (grid.isLight(r, c)) previewSet.add(`${r},${c}`);
|
||||
|
||||
for (const dest of candidates) {
|
||||
// Restore guard/throw baseline before simulating each candidate —
|
||||
// onTurnChange mutates guard position/direction/tier etc.
|
||||
guards.forEach((g, i) => g.apply(guardSnaps[i]));
|
||||
if (throwSystem && throwSnap) throwSystem.apply(throwSnap);
|
||||
|
||||
// Reaching the goal ends the level before guards react — no hazard.
|
||||
if (grid.isGoal(dest.row, dest.col)) continue;
|
||||
|
||||
grid.clearLight();
|
||||
if (throwSystem) throwSystem.resolve(guards);
|
||||
guards.forEach(g => g.onTurnChange(guards, dest));
|
||||
|
||||
for (let r = 0; r < grid.rows; r++) {
|
||||
for (let c = 0; c < grid.cols; c++) {
|
||||
if (grid.isLight(r, c) || grid.isWarm(r, c)) previewSet.add(`${r},${c}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore guard state
|
||||
// Undo everything the preview touched: guard/throw state, and the
|
||||
// warm transition applied above (the real turn has not happened yet).
|
||||
guards.forEach((g, i) => g.apply(guardSnaps[i]));
|
||||
if (throwSystem && throwSnap) throwSystem.apply(throwSnap);
|
||||
grid.applyWarmSnapshot(warmSnap);
|
||||
|
||||
grid.clearAllLight();
|
||||
grid.clearLight();
|
||||
guards.forEach(g => g.updateLight(guards));
|
||||
|
||||
return previewSet;
|
||||
|
||||
@@ -182,6 +182,112 @@ describe('TurnManager — throwSystem integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TurnManager — mobile guard co-location and swap detection', () => {
|
||||
it('detects the player standing on a stationary patrolling guard', () => {
|
||||
const grid = new GridSystem(3, 3, 50);
|
||||
// Path length 1 — the guard never advances, so this is pure co-location.
|
||||
const guard = new PatrollingGuard(grid, 1, 1, [{ row: 1, col: 1 }]);
|
||||
const player = new Player(grid, 1, 1);
|
||||
const tm = new TurnManager();
|
||||
const result = tm.nextTurn(grid, player, [guard]);
|
||||
expect(result.detected).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a same-turn swap where the player and a patrolling guard exchange cells', () => {
|
||||
const grid = new GridSystem(3, 3, 50);
|
||||
const guard = new PatrollingGuard(grid, 1, 1, [{ row: 1, col: 1 }, { row: 1, col: 2 }]);
|
||||
// Player's move this turn lands exactly on the guard's PRE-move cell,
|
||||
// while the guard advances away from it — a clean crossing that
|
||||
// end-of-turn co-location alone cannot see.
|
||||
const player = new Player(grid, 1, 1);
|
||||
const tm = new TurnManager();
|
||||
const result = tm.nextTurn(grid, player, [guard]);
|
||||
expect(guard.row).toBe(1);
|
||||
expect(guard.col).toBe(2);
|
||||
expect(result.detected).toBe(true);
|
||||
});
|
||||
|
||||
it('does not falsely detect when player and guard never share or cross a cell', () => {
|
||||
const grid = new GridSystem(5, 5, 50);
|
||||
const guard = new PatrollingGuard(grid, 0, 0, [{ row: 0, col: 0 }, { row: 0, col: 1 }]);
|
||||
const player = new Player(grid, 4, 4);
|
||||
const tm = new TurnManager();
|
||||
const result = tm.nextTurn(grid, player, [guard]);
|
||||
expect(result.detected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TurnManager — warm tiles are a lethal 1-turn afterglow', () => {
|
||||
it('a cell that goes dark this turn is warm and lethal to enter that same turn', () => {
|
||||
const grid = new GridSystem(5, 5, 50);
|
||||
grid.setDecayEligibleAll();
|
||||
const guard = new RotatingGuard(grid, 2, 0, 1); // facing right
|
||||
guard.updateLight([guard]); // paint initial light, as level load does
|
||||
expect(grid.isLight(2, 2)).toBe(true);
|
||||
|
||||
// Player's move this turn lands on the cell that is about to go dark.
|
||||
const player = new Player(grid, 2, 2);
|
||||
const tm = new TurnManager();
|
||||
const result = tm.nextTurn(grid, player, [guard]);
|
||||
|
||||
// Guard rotated away (now facing down) — (2,2) is no longer directly lit...
|
||||
expect(grid.isLight(2, 2)).toBe(false);
|
||||
// ...but it just went dark this turn, so it's warm, and warm is lethal.
|
||||
expect(result.detected).toBe(true);
|
||||
});
|
||||
|
||||
it('a cell is safe again the turn after its warm afterglow expires', () => {
|
||||
const grid = new GridSystem(5, 5, 50);
|
||||
grid.setDecayEligibleAll();
|
||||
const guard = new RotatingGuard(grid, 2, 0, 1);
|
||||
guard.updateLight([guard]);
|
||||
|
||||
const bystander = new Player(grid, 4, 4); // uninvolved, drives the first turn
|
||||
const tm = new TurnManager();
|
||||
tm.nextTurn(grid, bystander, [guard]); // (2,2) goes dark and becomes warm this turn
|
||||
|
||||
const player = new Player(grid, 2, 2); // arrives one turn later than the warm window
|
||||
const result = tm.nextTurn(grid, player, [guard]);
|
||||
expect(result.detected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TurnManager.previewNextTurn — truthful for player-reactive guards', () => {
|
||||
it('flags a cell as hazardous if ANY reachable next move would let a chaser catch it', () => {
|
||||
// Chaser at (0,0), detectionRadius 3. Player currently at (2,1) — out of
|
||||
// the chaser's immediate light — but moving to (2,0) brings the player
|
||||
// within range; the chaser then steps to (1,0) and lights (2,0). A
|
||||
// preview that only simulates the stationary player would miss this.
|
||||
const grid = new GridSystem(5, 5, 50);
|
||||
const player = new Player(grid, 2, 1);
|
||||
const chaser = new ChaserGuard(grid, 0, 0, 3);
|
||||
const tm = new TurnManager();
|
||||
|
||||
const preview = tm.previewNextTurn(grid, player, [chaser]);
|
||||
expect(preview.has('2,0')).toBe(true);
|
||||
|
||||
// Confirm it isn't a false alarm: actually moving there triggers real detection.
|
||||
player.row = 2; player.col = 0;
|
||||
const result = tm.nextTurn(grid, player, [chaser]);
|
||||
expect(result.detected).toBe(true);
|
||||
});
|
||||
|
||||
it('does not leave fabricated warm state on the real grid after the preview runs', () => {
|
||||
const grid = new GridSystem(5, 5, 50);
|
||||
grid.setDecayEligibleAll();
|
||||
const guard = new RotatingGuard(grid, 2, 0, 1);
|
||||
guard.updateLight([guard]); // (2,2) lit
|
||||
const player = new Player(grid, 4, 4);
|
||||
const tm = new TurnManager();
|
||||
|
||||
expect(grid.getWarmSnapshot()).toEqual([]);
|
||||
tm.previewNextTurn(grid, player, [guard]);
|
||||
// previewNextTurn internally calls clearAllLight (which would normally
|
||||
// mark (2,2) warm) — that side effect must not leak onto the real grid.
|
||||
expect(grid.getWarmSnapshot()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TurnManager — property test: capture/apply round-trip over 50 random turns', () => {
|
||||
// Build a small deterministic but varied scenario and verify that restoring
|
||||
// any mid-sequence snapshot and replaying produces identical state.
|
||||
|
||||
Reference in new issue
Block a user