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.
This commit is contained in:
tiennm99 committed 2026-04-26 08:27:21 +07:00
1 parent 89943829c0
commit 09f804c2ea
8 files changed
+1263 -13

No files matched your search

+266 -2
View File
@@ -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;
}
}
}
+126
View File
@@ -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([]);
});
});
+241 -3
View File
@@ -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
}
+203
View File
@@ -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 });
});
});
+91
View File
@@ -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;
}
}
+175
View File
@@ -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();
});
});
+26 -7
View File
@@ -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));
+135 -1
View File
@@ -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);
}
});
});