From 09f804c2ea0b0c4ba398aff6857aac0daa0e887b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 26 Apr 2026 08:27:21 +0700 Subject: [PATCH] feat(engine): add sniper, suspicion, throwable, doors/keys/one-way cells New cell flags (door, key, one-way, warm). SniperGuard with line-of-sight beam + mirror reflection. SuspicionGuard with 3-tier meter. ThrowableSystem with stone-throw distraction resolved before guard turn. Capture/apply round-trip preserves all new state. --- src/lib/game/grid-system.js | 268 +++++++++++++++++++++++++++++- src/lib/game/grid-system.test.js | 126 ++++++++++++++ src/lib/game/guards.js | 244 ++++++++++++++++++++++++++- src/lib/game/guards.test.js | 203 ++++++++++++++++++++++ src/lib/game/throwable.js | 91 ++++++++++ src/lib/game/throwable.test.js | 175 +++++++++++++++++++ src/lib/game/turn-manager.js | 33 +++- src/lib/game/turn-manager.test.js | 136 ++++++++++++++- 8 files changed, 1263 insertions(+), 13 deletions(-) create mode 100644 src/lib/game/throwable.js create mode 100644 src/lib/game/throwable.test.js diff --git a/src/lib/game/grid-system.js b/src/lib/game/grid-system.js index 1de5665..28a25c8 100644 --- a/src/lib/game/grid-system.js +++ b/src/lib/game/grid-system.js @@ -14,6 +14,20 @@ export class GridSystem { isWall: false, isGoal: false, isLight: false, + // Door/key mechanics + isDoor: false, + doorKeyId: null, + isKey: false, + keyId: null, + // One-way tile: dir is 0=up,1=right,2=down,3=left (allowed entry direction) + isOneWay: false, + oneWayDir: null, + // Warm tile: set for 1 turn after a lit cell goes dark (if decay-eligible) + isWarm: false, + warmTurnsLeft: 0, + // Decay-eligible: only these cells can become warm when light leaves them. + // Prevents state explosion — by default no cell is decay-eligible. + isDecayEligible: false, })) ); } @@ -28,6 +42,7 @@ export class GridSystem { return row >= 0 && row < this.rows && col >= 0 && col < this.cols; } + // --- Wall --- setWall(row, col, value) { if (this.isValidPosition(row, col)) this.grid[row][col].isWall = value; } @@ -36,6 +51,7 @@ export class GridSystem { return this.isValidPosition(row, col) && this.grid[row][col].isWall; } + // --- Goal --- setGoal(row, col, value) { if (this.isValidPosition(row, col)) this.grid[row][col].isGoal = value; } @@ -44,6 +60,7 @@ export class GridSystem { return this.isValidPosition(row, col) && this.grid[row][col].isGoal; } + // --- Light --- setLight(row, col, value) { if (this.isValidPosition(row, col)) this.grid[row][col].isLight = value; } @@ -52,11 +69,258 @@ export class GridSystem { return this.isValidPosition(row, col) && this.grid[row][col].isLight; } - // Clear all light from the grid + // --- Decay eligibility --- + // Only cells marked decay-eligible transition to isWarm when their light leaves. + // Use setDecayEligibleAll() for "all" or setDecayEligible(r,c,true) for selective. + setDecayEligible(row, col, value) { + if (this.isValidPosition(row, col)) this.grid[row][col].isDecayEligible = value; + } + + isDecayEligible(row, col) { + return this.isValidPosition(row, col) && this.grid[row][col].isDecayEligible; + } + + // Mark every non-wall cell as decay-eligible (for decayTiles: "all") + setDecayEligibleAll() { + for (let row = 0; row < this.rows; row++) { + for (let col = 0; col < this.cols; col++) { + if (!this.grid[row][col].isWall) { + this.grid[row][col].isDecayEligible = true; + } + } + } + } + + // 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). clearAllLight() { for (let row = 0; row < this.rows; row++) { for (let col = 0; col < this.cols; col++) { - this.grid[row][col].isLight = false; + const cell = this.grid[row][col]; + if (cell.isLight && cell.isDecayEligible && !cell.isWarm) { + // Cell was lit and is decay-eligible — mark warm for 1 turn + cell.isWarm = true; + cell.warmTurnsLeft = 1; + } + cell.isLight = false; + } + } + } + + // --- Door --- + setDoor(row, col, keyId) { + if (!this.isValidPosition(row, col)) return; + this.grid[row][col].isDoor = true; + this.grid[row][col].doorKeyId = keyId ?? null; + } + + isDoor(row, col) { + return this.isValidPosition(row, col) && this.grid[row][col].isDoor; + } + + getDoorKeyId(row, col) { + return this.isValidPosition(row, col) ? this.grid[row][col].doorKeyId : null; + } + + clearDoor(row, col) { + if (!this.isValidPosition(row, col)) return; + this.grid[row][col].isDoor = false; + this.grid[row][col].doorKeyId = null; + } + + // --- Key --- + setKey(row, col, keyId) { + if (!this.isValidPosition(row, col)) return; + this.grid[row][col].isKey = true; + this.grid[row][col].keyId = keyId ?? null; + } + + isKey(row, col) { + return this.isValidPosition(row, col) && this.grid[row][col].isKey; + } + + getKeyId(row, col) { + return this.isValidPosition(row, col) ? this.grid[row][col].keyId : null; + } + + clearKey(row, col) { + if (!this.isValidPosition(row, col)) return; + this.grid[row][col].isKey = false; + this.grid[row][col].keyId = null; + } + + // --- One-way tile --- + // dir: 0=up,1=right,2=down,3=left — the allowed movement direction when entering + setOneWay(row, col, dir) { + if (!this.isValidPosition(row, col)) return; + this.grid[row][col].isOneWay = true; + this.grid[row][col].oneWayDir = dir; + } + + isOneWay(row, col) { + return this.isValidPosition(row, col) && this.grid[row][col].isOneWay; + } + + getOneWayDir(row, col) { + return this.isValidPosition(row, col) ? this.grid[row][col].oneWayDir : null; + } + + // Returns true if the player can enter this cell moving in `moveDir` + // moveDir uses same encoding: 0=up,1=right,2=down,3=left + canEnterOneWay(row, col, moveDir) { + if (!this.isOneWay(row, col)) return true; + return this.grid[row][col].oneWayDir === moveDir; + } + + clearOneWay(row, col) { + if (!this.isValidPosition(row, col)) return; + this.grid[row][col].isOneWay = false; + this.grid[row][col].oneWayDir = null; + } + + // --- Warm tile --- + // Called externally (e.g. when a lit cell goes dark) to mark it warm for 1 turn + setWarm(row, col, turns = 1) { + if (!this.isValidPosition(row, col)) return; + this.grid[row][col].isWarm = true; + this.grid[row][col].warmTurnsLeft = turns; + } + + isWarm(row, col) { + return this.isValidPosition(row, col) && this.grid[row][col].isWarm; + } + + getWarmTurnsLeft(row, col) { + return this.isValidPosition(row, col) ? this.grid[row][col].warmTurnsLeft : 0; + } + + // Decrement all warm timers; clear isWarm when timer reaches 0. + // Called by turn-manager AFTER detection check each turn. + tickWarmTimers() { + for (let row = 0; row < this.rows; row++) { + for (let col = 0; col < this.cols; col++) { + const cell = this.grid[row][col]; + if (cell.isWarm) { + cell.warmTurnsLeft--; + if (cell.warmTurnsLeft <= 0) { + cell.isWarm = false; + cell.warmTurnsLeft = 0; + } + } + } + } + } + + // Restores warm-tile state from a snapshot returned by getWarmSnapshot(). + // Clears all existing warm state first, then reapplies [[r, c, t], ...]. + // Used by the BFS solver to restore grid warm timers when re-applying state. + applyWarmSnapshot(arr) { + // Clear all existing warm state + for (let row = 0; row < this.rows; row++) { + for (let col = 0; col < this.cols; col++) { + const cell = this.grid[row][col]; + if (cell.isWarm) { + cell.isWarm = false; + cell.warmTurnsLeft = 0; + } + } + } + // Reapply from snapshot + for (const [r, c, t] of arr) { + if (this.isValidPosition(r, c)) { + this.grid[r][c].isWarm = true; + this.grid[r][c].warmTurnsLeft = t; + } + } + } + + // Returns sorted sparse array [[r, c, t], ...] of all warm cells. + // Used by solver for state hashing. + getWarmSnapshot() { + const result = []; + for (let row = 0; row < this.rows; row++) { + for (let col = 0; col < this.cols; col++) { + const cell = this.grid[row][col]; + if (cell.isWarm) { + result.push([row, col, cell.warmTurnsLeft]); + } + } + } + // Already in row-major order (sorted by [r, c]) + return result; + } + + // Returns sorted sparse array [[r, c, keyId], ...] of all currently-closed doors. + // Used by solver for state hashing — doors can be opened (cleared) when player passes through. + getDoorSnapshot() { + const result = []; + for (let row = 0; row < this.rows; row++) { + for (let col = 0; col < this.cols; col++) { + const cell = this.grid[row][col]; + if (cell.isDoor) { + result.push([row, col, cell.doorKeyId]); + } + } + } + return result; + } + + // Restores door-cell state from a snapshot returned by getDoorSnapshot(). + // Clears all existing door cells first, then reapplies [[r, c, keyId], ...]. + applyDoorSnapshot(arr) { + for (let row = 0; row < this.rows; row++) { + for (let col = 0; col < this.cols; col++) { + const cell = this.grid[row][col]; + if (cell.isDoor) { + cell.isDoor = false; + cell.doorKeyId = null; + } + } + } + for (const [r, c, keyId] of arr) { + if (this.isValidPosition(r, c)) { + this.grid[r][c].isDoor = true; + this.grid[r][c].doorKeyId = keyId; + } + } + } + + // Returns sorted sparse array [[r, c, keyId], ...] of all cells that still have a key. + // Used by solver for state hashing — mirrors getWarmSnapshot pattern. + getKeySnapshot() { + const result = []; + for (let row = 0; row < this.rows; row++) { + for (let col = 0; col < this.cols; col++) { + const cell = this.grid[row][col]; + if (cell.isKey) { + result.push([row, col, cell.keyId]); + } + } + } + // Already in row-major order (sorted by [r, c]) + return result; + } + + // Restores key-cell state from a snapshot returned by getKeySnapshot(). + // Clears all existing key cells first, then reapplies [[r, c, keyId], ...]. + // Used by the BFS solver to restore grid key cells when re-applying state. + applyKeySnapshot(arr) { + // Clear all existing key cells + for (let row = 0; row < this.rows; row++) { + for (let col = 0; col < this.cols; col++) { + const cell = this.grid[row][col]; + if (cell.isKey) { + cell.isKey = false; + cell.keyId = null; + } + } + } + // Reapply from snapshot + for (const [r, c, keyId] of arr) { + if (this.isValidPosition(r, c)) { + this.grid[r][c].isKey = true; + this.grid[r][c].keyId = keyId; } } } diff --git a/src/lib/game/grid-system.test.js b/src/lib/game/grid-system.test.js index 15422b7..29713f1 100644 --- a/src/lib/game/grid-system.test.js +++ b/src/lib/game/grid-system.test.js @@ -46,3 +46,129 @@ describe('GridSystem', () => { expect(wall.isWall).toBe(true); }); }); + +describe('GridSystem — door/key flags', () => { + it('setDoor marks cell as door with keyId', () => { + const g = new GridSystem(3, 3, 50); + g.setDoor(1, 1, 'key-A'); + expect(g.isDoor(1, 1)).toBe(true); + expect(g.getDoorKeyId(1, 1)).toBe('key-A'); + }); + + it('clearDoor removes door flag', () => { + const g = new GridSystem(3, 3, 50); + g.setDoor(1, 1, 'key-A'); + g.clearDoor(1, 1); + expect(g.isDoor(1, 1)).toBe(false); + expect(g.getDoorKeyId(1, 1)).toBeNull(); + }); + + it('setKey marks cell as key with keyId', () => { + const g = new GridSystem(3, 3, 50); + g.setKey(0, 2, 'key-A'); + expect(g.isKey(0, 2)).toBe(true); + expect(g.getKeyId(0, 2)).toBe('key-A'); + }); + + it('clearKey removes key flag', () => { + const g = new GridSystem(3, 3, 50); + g.setKey(0, 2, 'key-A'); + g.clearKey(0, 2); + expect(g.isKey(0, 2)).toBe(false); + expect(g.getKeyId(0, 2)).toBeNull(); + }); + + it('ignores door/key setters on out-of-bounds positions', () => { + const g = new GridSystem(2, 2, 50); + g.setDoor(-1, 0, 'k'); + g.setKey(5, 5, 'k'); + expect(g.isDoor(-1, 0)).toBe(false); + expect(g.isKey(5, 5)).toBe(false); + }); +}); + +describe('GridSystem — one-way flags', () => { + it('setOneWay marks cell with allowed entry direction', () => { + const g = new GridSystem(3, 3, 50); + g.setOneWay(1, 1, 2); // only allow entry moving down + expect(g.isOneWay(1, 1)).toBe(true); + expect(g.getOneWayDir(1, 1)).toBe(2); + }); + + it('canEnterOneWay allows correct direction', () => { + const g = new GridSystem(3, 3, 50); + g.setOneWay(1, 1, 1); // allowed: moving right + expect(g.canEnterOneWay(1, 1, 1)).toBe(true); + expect(g.canEnterOneWay(1, 1, 0)).toBe(false); + expect(g.canEnterOneWay(1, 1, 3)).toBe(false); + }); + + it('canEnterOneWay returns true for non-one-way cells', () => { + const g = new GridSystem(3, 3, 50); + expect(g.canEnterOneWay(1, 1, 0)).toBe(true); + }); + + it('clearOneWay removes one-way flag', () => { + const g = new GridSystem(3, 3, 50); + g.setOneWay(0, 0, 3); + g.clearOneWay(0, 0); + expect(g.isOneWay(0, 0)).toBe(false); + expect(g.getOneWayDir(0, 0)).toBeNull(); + }); +}); + +describe('GridSystem — warm tiles', () => { + it('setWarm marks cell as warm with timer', () => { + const g = new GridSystem(3, 3, 50); + g.setWarm(1, 1, 1); + expect(g.isWarm(1, 1)).toBe(true); + expect(g.getWarmTurnsLeft(1, 1)).toBe(1); + }); + + it('tickWarmTimers decrements timer and clears isWarm at 0', () => { + const g = new GridSystem(3, 3, 50); + g.setWarm(0, 0, 1); + g.tickWarmTimers(); + expect(g.isWarm(0, 0)).toBe(false); + expect(g.getWarmTurnsLeft(0, 0)).toBe(0); + }); + + it('tickWarmTimers with timer > 1 decrements without clearing', () => { + const g = new GridSystem(3, 3, 50); + g.setWarm(0, 0, 3); + g.tickWarmTimers(); + expect(g.isWarm(0, 0)).toBe(true); + expect(g.getWarmTurnsLeft(0, 0)).toBe(2); + g.tickWarmTimers(); + g.tickWarmTimers(); + expect(g.isWarm(0, 0)).toBe(false); + }); + + it('tickWarmTimers only affects warm cells', () => { + const g = new GridSystem(3, 3, 50); + g.setWarm(0, 0, 1); + // (1,1) not warm + g.tickWarmTimers(); + expect(g.isWarm(1, 1)).toBe(false); + expect(g.getWarmTurnsLeft(1, 1)).toBe(0); + }); + + it('getWarmSnapshot returns sorted sparse array of warm cells', () => { + const g = new GridSystem(4, 4, 50); + g.setWarm(3, 1, 2); + g.setWarm(0, 2, 1); + g.setWarm(1, 0, 3); + const snap = g.getWarmSnapshot(); + // Should be sorted row-major: (0,2), (1,0), (3,1) + expect(snap).toEqual([ + [0, 2, 1], + [1, 0, 3], + [3, 1, 2], + ]); + }); + + it('getWarmSnapshot returns empty array when no warm cells', () => { + const g = new GridSystem(3, 3, 50); + expect(g.getWarmSnapshot()).toEqual([]); + }); +}); diff --git a/src/lib/game/guards.js b/src/lib/game/guards.js index c03a825..148d562 100644 --- a/src/lib/game/guards.js +++ b/src/lib/game/guards.js @@ -15,9 +15,10 @@ class Guard { onTurnChange() {} // Dynamic state snapshot — override in subclasses to add per-type fields. - // Used by GameHistory (undo/redo) and TurnManager.previewNextTurn. + // Used by GameHistory (undo/redo), TurnManager.previewNextTurn, and the BFS solver. + // `type` is included so solver's canonicalGuardKey can route without a back-reference. capture() { - return { row: this.row, col: this.col, direction: this.direction, isOn: this.isOn }; + return { type: this.type, row: this.row, col: this.col, direction: this.direction, isOn: this.isOn }; } apply(s) { @@ -82,6 +83,9 @@ export class RotatingGuard extends Guard { { row: 1, col: 0 }, // down { row: 0, col: -1 }, // left ]; + // Set by ThrowableSystem.resolve() to override normal rotation for N turns + this.forcedFacingTurns = 0; + this.forcedFacingTarget = null; } updateLight(allGuards) { @@ -124,9 +128,36 @@ export class RotatingGuard extends Guard { } onTurnChange(allGuards) { - this.direction = (this.direction + 1) % 4; + if (this.forcedFacingTurns > 0 && this.forcedFacingTarget) { + // Override normal rotation: face toward throw target + this.direction = computeFacingToward( + this.row, this.col, + this.forcedFacingTarget.row, this.forcedFacingTarget.col + ); + this.forcedFacingTurns--; + } else { + this.direction = (this.direction + 1) % 4; + } this.updateLight(allGuards); } + + capture() { + return { + ...super.capture(), + forcedFacingTurns: this.forcedFacingTurns, + forcedFacingTarget: this.forcedFacingTarget + ? { ...this.forcedFacingTarget } + : null, + }; + } + + apply(s) { + super.apply(s); + this.forcedFacingTurns = s.forcedFacingTurns ?? 0; + this.forcedFacingTarget = s.forcedFacingTarget + ? { ...s.forcedFacingTarget } + : null; + } } export class BlinkingGuard extends Guard { @@ -185,6 +216,9 @@ export class ChaserGuard extends Guard { this.isReturning = false; this.targetRow = row; this.targetCol = col; + // Set by ThrowableSystem.resolve() to override normal movement for N turns + this.forcedFacingTurns = 0; + this.forcedFacingTarget = null; } updateLight() { @@ -253,6 +287,10 @@ export class ChaserGuard extends Guard { isReturning: this.isReturning, targetRow: this.targetRow, targetCol: this.targetCol, + forcedFacingTurns: this.forcedFacingTurns, + forcedFacingTarget: this.forcedFacingTarget + ? { ...this.forcedFacingTarget } + : null, }; } @@ -262,12 +300,27 @@ export class ChaserGuard extends Guard { this.isReturning = s.isReturning; this.targetRow = s.targetRow; this.targetCol = s.targetCol; + this.forcedFacingTurns = s.forcedFacingTurns ?? 0; + this.forcedFacingTarget = s.forcedFacingTarget + ? { ...s.forcedFacingTarget } + : null; } // Chaser has two states: hunting player or returning home onTurnChange(allGuards, player) { if (!player) { this.updateLight(); return; } + if (this.forcedFacingTurns > 0 && this.forcedFacingTarget) { + // Distracted: face the throw target, don't move + this.direction = computeFacingToward( + this.row, this.col, + this.forcedFacingTarget.row, this.forcedFacingTarget.col + ); + this.forcedFacingTurns--; + this.updateLight(); + return; + } + const dist = Math.abs(this.row - player.row) + Math.abs(this.col - player.col); if (dist <= this.detectionRadius) { @@ -315,6 +368,9 @@ export class PatrollingGuard extends Guard { this.isCircularPath = this.checkIfCircularPath(); this.isReversing = false; this.updateInitialDirection(); + // Set by ThrowableSystem.resolve() to override normal movement for N turns + this.forcedFacingTurns = 0; + this.forcedFacingTarget = null; } checkIfCircularPath() { @@ -372,6 +428,10 @@ export class PatrollingGuard extends Guard { ...super.capture(), currentPathIndex: this.currentPathIndex, isReversing: this.isReversing, + forcedFacingTurns: this.forcedFacingTurns, + forcedFacingTarget: this.forcedFacingTarget + ? { ...this.forcedFacingTarget } + : null, }; } @@ -379,9 +439,24 @@ export class PatrollingGuard extends Guard { super.apply(s); this.currentPathIndex = s.currentPathIndex; this.isReversing = s.isReversing; + this.forcedFacingTurns = s.forcedFacingTurns ?? 0; + this.forcedFacingTarget = s.forcedFacingTarget + ? { ...s.forcedFacingTarget } + : null; } onTurnChange() { + if (this.forcedFacingTurns > 0 && this.forcedFacingTarget) { + // Distracted: face the throw target, don't advance path + this.direction = computeFacingToward( + this.row, this.col, + this.forcedFacingTarget.row, this.forcedFacingTarget.col + ); + this.forcedFacingTurns--; + this.updateLight(); + return; + } + if (this.path.length <= 1) return; let nextIndex; @@ -411,3 +486,166 @@ export class PatrollingGuard extends Guard { this.updateLight(); } } + +// --- SniperGuard --- +// Casts a line-of-sight beam from its position in `facing` direction. +// Beam stops at first wall, mirror, or grid edge. Bounces off mirrors +// up to 3 times (reuses RotatingGuard.castBeam logic). +// Rotates 90° CW every `rotateCadence` turns (default 2). +export class SniperGuard extends Guard { + constructor(grid, row, col, facing, rotateCadence = 2) { + super(grid, row, col, 'sniper'); + // facing: 0=up, 1=right, 2=down, 3=left + this.facing = facing ?? 0; + this.direction = this.facing; // keep `direction` in sync for rendering + this.rotateCadence = rotateCadence; + this.turnsSinceRotate = 0; + this.lightRange = Math.max(grid.rows, grid.cols); // effectively unbounded + this.facingDirs = [ + { row: -1, col: 0 }, // up + { row: 0, col: 1 }, // right + { row: 1, col: 0 }, // down + { row: 0, col: -1 }, // left + ]; + } + + updateLight(allGuards) { + if (this.grid.isValidPosition(this.row, this.col)) { + this.grid.setLight(this.row, this.col, true); + } + const dir = this.facingDirs[this.facing]; + this._castBeam(dir, this.row, this.col, allGuards, 0); + } + + // Beam travels until wall/edge; reflects off mirrors (up to 3 bounces). + // Uses same unlimited-step approach but respects grid bounds. + _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; + } + } + } + } + + _reflectDir(dir, reflectType) { + if (reflectType === 'cw') { + return { row: dir.col, col: -dir.row }; + } + return { row: -dir.col, col: dir.row }; + } + + onTurnChange(allGuards) { + this.turnsSinceRotate++; + if (this.turnsSinceRotate >= this.rotateCadence) { + this.facing = (this.facing + 1) % 4; + this.direction = this.facing; // keep direction in sync for rendering + this.turnsSinceRotate = 0; + } + this.updateLight(allGuards); + } + + capture() { + return { + ...super.capture(), + facing: this.facing, + turnsSinceRotate: this.turnsSinceRotate, + }; + } + + apply(s) { + super.apply(s); + this.facing = s.facing; + this.direction = s.facing; // keep in sync + this.turnsSinceRotate = s.turnsSinceRotate; + } +} + +// --- 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. +export class SuspicionGuard extends Guard { + constructor(grid, row, col, range) { + super(grid, row, col, 'suspicion'); + this.range = range ?? 3; + this.tier = 0; + } + + 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++) { + const r = this.row + dr; + const c = this.col + dc; + if (this.grid.isValidPosition(r, c)) { + this.grid.setLight(r, c, true); + } + } + } + } + + 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) { + this.tier = Math.min(2, this.tier + 1); + } else { + this.tier = Math.max(0, this.tier - 1); + } + } else { + this.tier = Math.max(0, this.tier - 1); + } + + this.updateLight(); + } + + capture() { + return { + ...super.capture(), + tier: this.tier, + range: this.range, + }; + } + + apply(s) { + super.apply(s); + this.tier = s.tier; + this.range = s.range; + } +} + +// --- Shared utility --- +// Returns cardinal direction index (0=up,1=right,2=down,3=left) from +// (fromRow,fromCol) toward (toRow,toCol). Falls back to 0 if same cell. +function computeFacingToward(fromRow, fromCol, toRow, toCol) { + const dr = toRow - fromRow; + const dc = toCol - fromCol; + if (Math.abs(dr) >= Math.abs(dc)) { + return dr >= 0 ? 2 : 0; // down or up + } + return dc >= 0 ? 1 : 3; // right or left +} diff --git a/src/lib/game/guards.test.js b/src/lib/game/guards.test.js index 4b64c52..623f11c 100644 --- a/src/lib/game/guards.test.js +++ b/src/lib/game/guards.test.js @@ -3,6 +3,7 @@ import { GridSystem } from './grid-system.js'; import { StaticGuard, RotatingGuard, BlinkingGuard, MirrorGuard, PatrollingGuard, ChaserGuard, + SniperGuard, SuspicionGuard, } from './guards.js'; describe('Guard.capture()/apply()', () => { @@ -189,3 +190,205 @@ describe('PatrollingGuard', () => { expect(g.isReversing).toBe(false); }); }); + +describe('SniperGuard', () => { + it('beam lights cells in facing direction up to wall', () => { + const grid = new GridSystem(7, 7, 50); + grid.setWall(2, 4, true); + // facing right (1) from (2,1) + const g = new SniperGuard(grid, 2, 1, 1); + g.updateLight([g]); + expect(grid.isLight(2, 1)).toBe(true); // own cell + expect(grid.isLight(2, 2)).toBe(true); + expect(grid.isLight(2, 3)).toBe(true); + expect(grid.isLight(2, 4)).toBe(false); // wall blocks + expect(grid.isLight(2, 5)).toBe(false); // beyond wall + }); + + it('beam stops at grid edge', () => { + const grid = new GridSystem(5, 5, 50); + // facing up (0) from (2,2) + const g = new SniperGuard(grid, 2, 2, 0); + g.updateLight([g]); + expect(grid.isLight(1, 2)).toBe(true); + expect(grid.isLight(0, 2)).toBe(true); + // no crash — edge handled + }); + + it('beam reflects off mirror (cw) and continues', () => { + const grid = new GridSystem(7, 7, 50); + // sniper at (3,0) facing right, mirror at (3,3) + const sniper = new SniperGuard(grid, 3, 0, 1); + const mirror = new MirrorGuard(grid, 3, 3, 'cw'); + sniper.updateLight([sniper, mirror]); + expect(grid.isLight(3, 1)).toBe(true); + expect(grid.isLight(3, 2)).toBe(true); + expect(grid.isLight(3, 3)).toBe(true); // mirror cell + // cw reflect of right → down + expect(grid.isLight(4, 3)).toBe(true); + expect(grid.isLight(5, 3)).toBe(true); + expect(grid.isLight(6, 3)).toBe(true); + }); + + it('beam reflect is capped at 3 bounces (no infinite loop)', () => { + const grid = new GridSystem(9, 9, 50); + // Three mirrors arranged to form a bounce chain + const sniper = new SniperGuard(grid, 0, 0, 1); // facing right + const m1 = new MirrorGuard(grid, 0, 3, 'cw'); // bounce 1: right→down + const m2 = new MirrorGuard(grid, 3, 3, 'ccw'); // bounce 2: down→right + const m3 = new MirrorGuard(grid, 3, 6, 'cw'); // bounce 3: right→down + const m4 = new MirrorGuard(grid, 6, 6, 'ccw'); // would be bounce 4 — capped + // Should not throw; just completes with ≤3 bounces + expect(() => sniper.updateLight([sniper, m1, m2, m3, m4])).not.toThrow(); + }); + + it('rotates facing 90° CW after rotateCadence turns', () => { + const grid = new GridSystem(5, 5, 50); + const g = new SniperGuard(grid, 2, 2, 0, 2); // facing up, cadence 2 + expect(g.facing).toBe(0); + g.onTurnChange([g]); // turnsSinceRotate=1, no rotate + expect(g.facing).toBe(0); + g.onTurnChange([g]); // turnsSinceRotate=2 → rotate + expect(g.facing).toBe(1); + g.onTurnChange([g]); + expect(g.facing).toBe(1); // still 1 after 1 more + g.onTurnChange([g]); + expect(g.facing).toBe(2); // rotated again + }); + + it('rotateCadence=1 rotates every turn', () => { + const grid = new GridSystem(5, 5, 50); + const g = new SniperGuard(grid, 2, 2, 0, 1); + g.onTurnChange([g]); + expect(g.facing).toBe(1); + g.onTurnChange([g]); + expect(g.facing).toBe(2); + }); + + it('capture/apply round-trips facing and turnsSinceRotate', () => { + const grid = new GridSystem(5, 5, 50); + const g = new SniperGuard(grid, 2, 2, 3, 2); + g.turnsSinceRotate = 1; + const s = g.capture(); + g.facing = 0; + g.turnsSinceRotate = 99; + g.apply(s); + expect(g.facing).toBe(3); + expect(g.turnsSinceRotate).toBe(1); + }); +}); + +describe('SuspicionGuard', () => { + it('tier increments when player is in range', () => { + const grid = new GridSystem(7, 7, 50); + const guard = new SuspicionGuard(grid, 3, 3, 3); + const player = { row: 3, col: 5 }; // Manhattan dist 2 + expect(guard.tier).toBe(0); + guard.onTurnChange([], player); + 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); + }); + + it('tier drops to 0 when starting turn at tier 2 (firing), regardless of player distance', () => { + 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(0); + }); + + it('tier decrements when player is out of range (from tier 1)', () => { + const grid = new GridSystem(7, 7, 50); + const guard = new SuspicionGuard(grid, 0, 0, 2); + guard.tier = 1; + const farPlayer = { row: 6, col: 6 }; // dist 12 + guard.onTurnChange([], farPlayer); + expect(guard.tier).toBe(0); + }); + + it('tier clamps at 0 when decrementing from 0', () => { + const grid = new GridSystem(5, 5, 50); + const guard = new SuspicionGuard(grid, 2, 2, 2); + const farPlayer = { row: 0, col: 0 }; + guard.onTurnChange([], farPlayer); + expect(guard.tier).toBe(0); + }); + + it('at tier 2, lights own cell and all 8 neighbours', () => { + const grid = new GridSystem(5, 5, 50); + const guard = new SuspicionGuard(grid, 2, 2, 5); + guard.tier = 2; + guard.updateLight(); + for (let dr = -1; dr <= 1; dr++) { + for (let dc = -1; dc <= 1; dc++) { + expect(grid.isLight(2 + dr, 2 + dc)).toBe(true); + } + } + }); + + it('at tier 0, lights nothing', () => { + const grid = new GridSystem(5, 5, 50); + const guard = new SuspicionGuard(grid, 2, 2, 5); + guard.tier = 0; + guard.updateLight(); + for (let r = 0; r < 5; r++) + for (let c = 0; c < 5; c++) + expect(grid.isLight(r, c)).toBe(false); + }); + + it('firing tier lights cells even at grid edge (no crash)', () => { + const grid = new GridSystem(5, 5, 50); + const guard = new SuspicionGuard(grid, 0, 0, 5); + guard.tier = 2; + expect(() => guard.updateLight()).not.toThrow(); + expect(grid.isLight(0, 0)).toBe(true); + expect(grid.isLight(0, 1)).toBe(true); + expect(grid.isLight(1, 0)).toBe(true); + }); + + it('capture/apply round-trips tier and range', () => { + const grid = new GridSystem(5, 5, 50); + const guard = new SuspicionGuard(grid, 2, 2, 4); + guard.tier = 1; + const s = guard.capture(); + guard.tier = 0; + guard.range = 99; + guard.apply(s); + expect(guard.tier).toBe(1); + expect(guard.range).toBe(4); + }); +}); + +describe('RotatingGuard — forcedFacing (throwable distraction)', () => { + it('honors forcedFacingTurns when set, overriding normal rotation', () => { + const grid = new GridSystem(5, 5, 50); + const g = new RotatingGuard(grid, 2, 2, 0); // facing up + g.forcedFacingTurns = 1; + g.forcedFacingTarget = { row: 2, col: 4 }; // to the right + g.onTurnChange([g]); + // Should face right (1) not rotate to right(1) by normal increment — still 1 + expect(g.direction).toBe(1); + expect(g.forcedFacingTurns).toBe(0); + // Next turn: normal rotation resumes + g.onTurnChange([g]); + expect(g.direction).toBe(2); // normal +1 from 1 + }); + + it('capture/apply preserves forcedFacing fields', () => { + const grid = new GridSystem(5, 5, 50); + const g = new RotatingGuard(grid, 2, 2, 0); + g.forcedFacingTurns = 2; + g.forcedFacingTarget = { row: 4, col: 4 }; + const s = g.capture(); + g.forcedFacingTurns = 0; + g.forcedFacingTarget = null; + g.apply(s); + expect(g.forcedFacingTurns).toBe(2); + expect(g.forcedFacingTarget).toEqual({ row: 4, col: 4 }); + }); +}); diff --git a/src/lib/game/throwable.js b/src/lib/game/throwable.js new file mode 100644 index 0000000..822b469 --- /dev/null +++ b/src/lib/game/throwable.js @@ -0,0 +1,91 @@ +// ThrowableSystem — manages thrown stones mechanic. +// 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; +} + +export class ThrowableSystem { + constructor(stonesLeft = 0) { + this.stonesLeft = stonesLeft; + this.pendingTarget = null; // { row, col } | null + } + + // Attempt to throw a stone at (targetRow, targetCol). + // Validates: Manhattan ≤ 3 from player, line-of-sight (no wall between), + // and stonesLeft > 0. + // Returns true on success, false on failure. + throw(targetRow, targetCol, player, grid) { + if (this.stonesLeft <= 0) return false; + + const dist = Math.abs(targetRow - player.row) + Math.abs(targetCol - player.col); + if (dist > 3) return false; + + if (!hasLineOfSight(grid, player.row, player.col, targetRow, targetCol)) { + return false; + } + + this.stonesLeft--; + this.pendingTarget = { row: targetRow, col: targetCol }; + return true; + } + + // Resolve the pending throw: for each distractible guard within Manhattan ≤ 2 + // of the pending target, set forcedFacingTurns=1 and forcedFacingTarget. + // Called by TurnManager BEFORE guards.forEach(onTurnChange). + // No-op if no pending target. + resolve(guards) { + if (!this.pendingTarget) return; + const { row: tr, col: tc } = this.pendingTarget; + + for (const guard of guards) { + if (!DISTRACTIBLE_TYPES.has(guard.type)) continue; + const dist = Math.abs(guard.row - tr) + Math.abs(guard.col - tc); + if (dist <= 2) { + guard.forcedFacingTurns = 1; + guard.forcedFacingTarget = { row: tr, col: tc }; + } + } + + this.pendingTarget = null; + } + + // Snapshot for undo / preview (non-destructive turn simulation) + capture() { + return { + stonesLeft: this.stonesLeft, + pendingTarget: this.pendingTarget + ? { ...this.pendingTarget } + : null, + }; + } + + apply(s) { + this.stonesLeft = s.stonesLeft; + this.pendingTarget = s.pendingTarget + ? { ...s.pendingTarget } + : null; + } + + // Reset to initial count, clearing any pending throw + reset(stones) { + this.stonesLeft = stones; + this.pendingTarget = null; + } +} diff --git a/src/lib/game/throwable.test.js b/src/lib/game/throwable.test.js new file mode 100644 index 0000000..61f384f --- /dev/null +++ b/src/lib/game/throwable.test.js @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GridSystem } from './grid-system.js'; +import { RotatingGuard, PatrollingGuard, ChaserGuard, StaticGuard, BlinkingGuard } from './guards.js'; +import { ThrowableSystem } from './throwable.js'; + +// Helper: build a minimal player-like object +function makePlayer(row, col) { + return { row, col }; +} + +describe('ThrowableSystem.throw — validation', () => { + let grid, ts, player; + + beforeEach(() => { + grid = new GridSystem(9, 9, 50); + ts = new ThrowableSystem(3); + player = makePlayer(4, 4); + }); + + it('succeeds when target is within Manhattan 3 and no walls', () => { + expect(ts.throw(4, 7, player, grid)).toBe(true); + expect(ts.stonesLeft).toBe(2); + expect(ts.pendingTarget).toEqual({ row: 4, col: 7 }); + }); + + it('fails when target is more than Manhattan 3 away', () => { + expect(ts.throw(4, 8, player, grid)).toBe(false); + expect(ts.stonesLeft).toBe(3); + expect(ts.pendingTarget).toBeNull(); + }); + + it('fails when stonesLeft is 0', () => { + const empty = new ThrowableSystem(0); + expect(empty.throw(4, 5, player, grid)).toBe(false); + }); + + it('fails when a wall blocks line of sight', () => { + // Wall sits between player (4,4) and target (4,6) + grid.setWall(4, 5, true); + expect(ts.throw(4, 6, player, grid)).toBe(false); + expect(ts.stonesLeft).toBe(3); + }); + + it('succeeds when path is clear diagonally within range', () => { + // Manhattan(4,4→5,6) = |5-4|+|6-4| = 1+2 = 3 → valid + const ts2 = new ThrowableSystem(1); + expect(ts2.throw(5, 6, player, grid)).toBe(true); + }); + + it('fails when target is exactly Manhattan 4 away', () => { + // Manhattan(4,4→4,8) = 4 > 3 → invalid + expect(ts.throw(4, 8, player, grid)).toBe(false); + }); + + it('allows throwing at own cell (dist 0)', () => { + expect(ts.throw(4, 4, player, grid)).toBe(true); + }); +}); + +describe('ThrowableSystem.resolve — guard distraction', () => { + let grid; + + beforeEach(() => { + grid = new GridSystem(9, 9, 50); + }); + + it('sets forcedFacing on rotating guard within radius 2', () => { + const ts = new ThrowableSystem(1); + const rot = new RotatingGuard(grid, 4, 4, 0); + ts.pendingTarget = { row: 4, col: 6 }; + ts.resolve([rot]); + expect(rot.forcedFacingTurns).toBe(1); + expect(rot.forcedFacingTarget).toEqual({ row: 4, col: 6 }); + expect(ts.pendingTarget).toBeNull(); + }); + + it('sets forcedFacing on patrolling guard within radius 2', () => { + const ts = new ThrowableSystem(1); + const path = [{ row: 3, col: 3 }, { row: 3, col: 5 }]; + const pat = new PatrollingGuard(grid, 3, 3, path); + ts.pendingTarget = { row: 3, col: 5 }; + ts.resolve([pat]); + expect(pat.forcedFacingTurns).toBe(1); + }); + + it('sets forcedFacing on chaser guard within radius 2', () => { + const ts = new ThrowableSystem(1); + const ch = new ChaserGuard(grid, 5, 5, 3); + ts.pendingTarget = { row: 5, col: 7 }; // dist 2 from (5,5) + ts.resolve([ch]); + expect(ch.forcedFacingTurns).toBe(1); + }); + + it('does NOT distract guard outside radius 2', () => { + const ts = new ThrowableSystem(1); + const rot = new RotatingGuard(grid, 0, 0, 0); + ts.pendingTarget = { row: 4, col: 6 }; // dist from (0,0) = 10 + ts.resolve([rot]); + expect(rot.forcedFacingTurns).toBe(0); + }); + + it('does NOT distract static guard even if in radius', () => { + const ts = new ThrowableSystem(1); + const stat = new StaticGuard(grid, 4, 5, 2); + ts.pendingTarget = { row: 4, col: 6 }; // dist 1 + ts.resolve([stat]); + expect(stat.forcedFacingTurns).toBeUndefined(); + }); + + it('does NOT distract blinking guard', () => { + const ts = new ThrowableSystem(1); + const blink = new BlinkingGuard(grid, 4, 5, [{ row: 3, col: 5 }], true); + ts.pendingTarget = { row: 4, col: 6 }; + ts.resolve([blink]); + expect(blink.forcedFacingTurns).toBeUndefined(); + }); + + it('is a no-op when no pending target', () => { + const ts = new ThrowableSystem(1); + const rot = new RotatingGuard(grid, 4, 4, 0); + ts.resolve([rot]); + expect(rot.forcedFacingTurns).toBe(0); + }); + + it('distracts multiple eligible guards simultaneously', () => { + const ts = new ThrowableSystem(1); + const r1 = new RotatingGuard(grid, 4, 5, 0); + const r2 = new RotatingGuard(grid, 5, 5, 0); + ts.pendingTarget = { row: 4, col: 6 }; + ts.resolve([r1, r2]); + expect(r1.forcedFacingTurns).toBe(1); + expect(r2.forcedFacingTurns).toBe(1); + }); +}); + +describe('ThrowableSystem.capture/apply round-trip', () => { + it('preserves stonesLeft and pendingTarget', () => { + const ts = new ThrowableSystem(5); + ts.stonesLeft = 3; + ts.pendingTarget = { row: 2, col: 3 }; + const s = ts.capture(); + ts.stonesLeft = 0; + ts.pendingTarget = null; + ts.apply(s); + expect(ts.stonesLeft).toBe(3); + expect(ts.pendingTarget).toEqual({ row: 2, col: 3 }); + }); + + it('preserves null pendingTarget', () => { + const ts = new ThrowableSystem(2); + const s = ts.capture(); + ts.pendingTarget = { row: 1, col: 1 }; + ts.apply(s); + expect(ts.pendingTarget).toBeNull(); + }); + + it('pendingTarget clone is independent (no reference sharing)', () => { + const ts = new ThrowableSystem(2); + ts.pendingTarget = { row: 1, col: 1 }; + const s = ts.capture(); + s.pendingTarget.row = 99; // mutate snapshot + expect(ts.pendingTarget.row).toBe(1); // original unaffected + }); +}); + +describe('ThrowableSystem.reset', () => { + it('resets stonesLeft and clears pendingTarget', () => { + const ts = new ThrowableSystem(3); + ts.stonesLeft = 1; + ts.pendingTarget = { row: 0, col: 0 }; + ts.reset(5); + expect(ts.stonesLeft).toBe(5); + expect(ts.pendingTarget).toBeNull(); + }); +}); diff --git a/src/lib/game/turn-manager.js b/src/lib/game/turn-manager.js index f1faf53..448d05d 100644 --- a/src/lib/game/turn-manager.js +++ b/src/lib/game/turn-manager.js @@ -5,18 +5,29 @@ export class TurnManager { this.turnCount = 0; } - // Process a turn: update guards, check detection + // Process a turn: update guards, check detection. + // Turn order: + // 1. Goal check (early-out) + // 2. throwSystem?.resolve(guards) — stone lands, guards face it + // 3. grid.clearAllLight() + // 4. guards.forEach onTurnChange — guards move/rotate, emit light + // 5. grid.tickWarmTimers() — warm cells expire + // 6. Detection check // Returns { detected, levelComplete } - nextTurn(grid, player, guards) { + nextTurn(grid, player, guards, throwSystem = null) { this.turnCount++; if (grid.isGoal(player.row, player.col)) { return { detected: false, levelComplete: true }; } + if (throwSystem) throwSystem.resolve(guards); + grid.clearAllLight(); guards.forEach(guard => guard.onTurnChange(guards, player)); + grid.tickWarmTimers(); + if (grid.isLight(player.row, player.col)) { return { detected: true, levelComplete: false }; } @@ -25,12 +36,17 @@ export class TurnManager { } // Simulate next turn to preview future lit cells (non-destructive). - // Uses each guard's capture()/apply() so new dynamic fields are picked up - // automatically without touching this method. - previewNextTurn(grid, player, guards) { - const snaps = guards.map(g => g.capture()); + // Captures and restores guard state AND throwSystem state so the preview + // leaves nothing mutated. + previewNextTurn(grid, player, guards, throwSystem = null) { + const guardSnaps = guards.map(g => g.capture()); + const throwSnap = throwSystem ? throwSystem.capture() : null; grid.clearAllLight(); + + // Apply throw distraction before guard update (same as nextTurn) + if (throwSystem) throwSystem.resolve(guards); + guards.forEach(g => g.onTurnChange(guards, player)); const previewSet = new Set(); @@ -40,7 +56,10 @@ export class TurnManager { } } - guards.forEach((g, i) => g.apply(snaps[i])); + // Restore guard state + guards.forEach((g, i) => g.apply(guardSnaps[i])); + if (throwSystem && throwSnap) throwSystem.apply(throwSnap); + grid.clearAllLight(); guards.forEach(g => g.updateLight(guards)); diff --git a/src/lib/game/turn-manager.test.js b/src/lib/game/turn-manager.test.js index 18fea44..485a16b 100644 --- a/src/lib/game/turn-manager.test.js +++ b/src/lib/game/turn-manager.test.js @@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { GridSystem } from './grid-system.js'; import { Player } from './player.js'; import { TurnManager } from './turn-manager.js'; -import { RotatingGuard, PatrollingGuard, ChaserGuard, BlinkingGuard } from './guards.js'; +import { RotatingGuard, PatrollingGuard, ChaserGuard, BlinkingGuard, SniperGuard, SuspicionGuard } from './guards.js'; +import { ThrowableSystem } from './throwable.js'; describe('TurnManager.nextTurn', () => { it('reports levelComplete when player is on goal', () => { @@ -113,3 +114,136 @@ describe('TurnManager.previewNextTurn — non-destructive', () => { expect(actual).toEqual(predicted); }); }); + +describe('TurnManager — throwSystem integration', () => { + it('resolves throw BEFORE guard onTurnChange', () => { + // Guard at (2,2) facing up (0). If distracted toward (2,4), it should face right. + const grid = new GridSystem(5, 5, 50); + const player = new Player(grid, 0, 0); + const g = new RotatingGuard(grid, 2, 2, 0); + const ts = new ThrowableSystem(1); + ts.pendingTarget = { row: 2, col: 4 }; // manually set pending (stone already thrown) + + const tm = new TurnManager(); + tm.nextTurn(grid, player, [g], ts); + + // Guard should have faced right (toward target) not up+1=right by accident + // Forced facing: from (2,2) toward (2,4) → right=1 + expect(g.direction).toBe(1); + expect(g.forcedFacingTurns).toBe(0); + expect(ts.pendingTarget).toBeNull(); + }); + + it('nextTurn with null throwSystem works normally', () => { + const grid = new GridSystem(3, 3, 50); + const player = new Player(grid, 0, 0); + const tm = new TurnManager(); + expect(() => tm.nextTurn(grid, player, [], null)).not.toThrow(); + }); + + it('tickWarmTimers is called after guard update each turn', () => { + const grid = new GridSystem(5, 5, 50); + grid.setWarm(1, 1, 1); // should expire after this turn + const player = new Player(grid, 0, 0); + const tm = new TurnManager(); + tm.nextTurn(grid, player, []); + expect(grid.isWarm(1, 1)).toBe(false); + }); + + it('previewNextTurn does not mutate throwSystem state', () => { + const grid = new GridSystem(5, 5, 50); + const player = new Player(grid, 0, 0); + const g = new RotatingGuard(grid, 2, 2, 0); + const ts = new ThrowableSystem(2); + ts.pendingTarget = { row: 2, col: 4 }; + const origStones = ts.stonesLeft; + + const tm = new TurnManager(); + tm.previewNextTurn(grid, player, [g], ts); + + // stonesLeft and pendingTarget must be restored + expect(ts.stonesLeft).toBe(origStones); + expect(ts.pendingTarget).toEqual({ row: 2, col: 4 }); + }); + + it('previewNextTurn restores guard forcedFacing after throw preview', () => { + const grid = new GridSystem(5, 5, 50); + const player = new Player(grid, 0, 0); + const g = new RotatingGuard(grid, 2, 2, 0); + const ts = new ThrowableSystem(1); + ts.pendingTarget = { row: 2, col: 4 }; + + const tm = new TurnManager(); + tm.previewNextTurn(grid, player, [g], ts); + + // Guard must be back to original direction with no forcedFacing + expect(g.direction).toBe(0); + expect(g.forcedFacingTurns).toBe(0); + }); +}); + +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. + it('state is reproducible from any snapshot after 50 turns', () => { + const grid = new GridSystem(7, 7, 50); + grid.setWall(3, 3, true); + + const rotGuard = new RotatingGuard(grid, 1, 1, 0); + const patGuard = new PatrollingGuard(grid, 5, 1, [ + { row: 5, col: 1 }, { row: 5, col: 3 }, { row: 5, col: 5 }, + ]); + const chaseGuard = new ChaserGuard(grid, 6, 6, 3); + const sniperGuard = new SniperGuard(grid, 0, 6, 2, 2); + const suspGuard = new SuspicionGuard(grid, 3, 6, 3); + const ts = new ThrowableSystem(10); + + const player = new Player(grid, 0, 0); + const guards = [rotGuard, patGuard, chaseGuard, sniperGuard, suspGuard]; + const tm = new TurnManager(); + + // Capture snapshots at every turn + const snapshots = []; + for (let i = 0; i < 50; i++) { + snapshots.push({ + guards: guards.map(g => g.capture()), + ts: ts.capture(), + turnCount: tm.turnCount, + }); + tm.nextTurn(grid, player, guards, ts); + } + + // Pick 5 evenly-spaced checkpoints and replay forward from them + const checkpoints = [0, 10, 20, 35, 48]; + for (const startIdx of checkpoints) { + // Restore from snapshot at startIdx + const snap = snapshots[startIdx]; + guards.forEach((g, i) => g.apply(snap.guards[i])); + ts.apply(snap.ts); + tm.turnCount = snap.turnCount; + + // Replay from startIdx to 49 + for (let i = startIdx; i < 50; i++) { + tm.nextTurn(grid, player, guards, ts); + } + + // Capture final state + const replayedSnaps = guards.map(g => g.capture()); + const replayedTs = ts.capture(); + + // Reset and replay fresh from startIdx for comparison + guards.forEach((g, i) => g.apply(snap.guards[i])); + ts.apply(snap.ts); + tm.turnCount = snap.turnCount; + + for (let i = startIdx; i < 50; i++) { + tm.nextTurn(grid, player, guards, ts); + } + + guards.forEach((g, i) => { + expect(g.capture()).toEqual(replayedSnaps[i]); + }); + expect(ts.capture()).toEqual(replayedTs); + } + }); +});