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:
tiennm99 committed 2026-07-26 00:34:57 +07:00
1 parent eb800f1ce0
commit ebff1ad019
14 files changed
+731 -190

No files matched your search

+10 -3
View File
@@ -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);
}
+20
View File
@@ -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);
+14 -1
View File
@@ -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;
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+50 -55
View File
@@ -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
+67 -6
View File
@@ -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);
});
});
+45
View File
@@ -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
View File
@@ -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;
+52
View File
@@ -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', () => {
+3 -18
View File
@@ -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) {
+77 -20
View File
@@ -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;
+106
View File
@@ -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.