diff --git a/docs/REMEDIATION_PLAN.md b/docs/REMEDIATION_PLAN.md index 40deffe..cfd67a7 100644 --- a/docs/REMEDIATION_PLAN.md +++ b/docs/REMEDIATION_PLAN.md @@ -39,8 +39,8 @@ Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker ## Phase 3 — Game integrity (🟠) - [x] **3.1 S — Anti-instant-death grace only (NO fairness floor).** ✅ Random block timing is the game's core mechanic and is deliberately preserved — early entry is a knowingly-risky bet, not a bug. The only change is a tiny configurable grace (`GAME_START_GRACE_MS`, default 2000ms, 0 to disable) so a block landing the instant a game starts can't kill the player before the dungeon renders / before their first move is possible. _(Rejected the originally-planned uniform time budget — it would gut the random-timing mechanic.)_ -- [ ] **3.2 S — Reachability guarantee** entrance→treasure→exit (`src/game/dungeon.js`). -- [ ] **3.3 S — Swap/passthrough collision fix** (`src/game/game.js:181`). +- [x] **3.2 S — Reachability guarantee.** ✅ `DungeonGenerator.generate` now BFS-checks entrance→exit reachability and regenerates if unreachable (paid-but-unwinnable guard). Retries are **deterministic** (per-attempt ROT seed = `seedInt+attempt`, seeded rng stream advances), so verify-regeneration replays the same attempts and reproduces the exact dungeon — provably-fair determinism preserved. New `isReachable` + tests (25 seeds all reachable; walled-off target rejected). +- [x] **3.3 S — Multi-step monster collision fix.** ✅ Collision is now checked after **each** monster sub-step, not just after the loop. With `movesPerPlayerMove > 1` (casino's 1.5× speed) the monster could previously step onto the player's tile and off again between checks, phasing through and missing the catch (player-favorable, eroded the house edge). New `monsterCollision.test.js`. ## Phase 4 — De-spaghetti / structural cleanup (🟡) - [ ] **4.1 L — Break up `index.js`** into `app.js`, `routes/{public,user,admin,auth}.js`, `views/verify.js`, `server.js`. diff --git a/src/game/dungeon.js b/src/game/dungeon.js index dd35923..1aa6267 100644 --- a/src/game/dungeon.js +++ b/src/game/dungeon.js @@ -118,21 +118,46 @@ class DungeonGenerator { secondaryFloor: DUNGEON_CONFIGS.SECONDARY_FLOOR, torchTile: DUNGEON_CONFIGS.TORCH_TILE }; - + const config = { ...defaultOptions, ...options }; if (CONSOLE_LOGGING) { console.log(`[DungeonGenerator] Generating dungeon with effective torchDensity: ${config.torchDensity}`); } - // PROVABLY FAIR (Phase 0.2): when a per-game seeded RNG is supplied, all - // randomness in generation is derived from the committed seed so the dungeon - // is reproducible for verification. `config.rng` drives floor/torch/treasure - // placement; `config.seedInt` deterministically seeds ROT.RNG for the Digger. - // Generation is fully synchronous, so seeding the global ROT.RNG here is safe - // even with concurrent games (no other game can interleave mid-generation). + // PROVABLY FAIR (Phase 0.2): when a per-game seeded RNG is supplied, all randomness + // in generation is derived from the committed seed so the dungeon is reproducible. const rng = (typeof config.rng === 'function') ? config.rng : Math.random; - if (config.seedInt != null) { - ROT.RNG.setSeed(config.seedInt); + const baseSeedInt = config.seedInt; + + // REACHABILITY (Phase 3.2): a dungeon whose exit is unreachable from the entrance + // is paid-but-unwinnable. The digger normally connects all rooms, so this is rare, + // but guard it. Retries are DETERMINISTIC (per-attempt ROT seed = baseSeedInt+attempt + // and the seeded rng stream keeps advancing), so verify regeneration replays the same + // attempts and reproduces the exact same final dungeon. + const MAX_ATTEMPTS = 12; + let last = null; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const attemptSeed = (baseSeedInt != null) ? baseSeedInt + attempt : null; + const dungeon = this._generateOnce(width, height, config, rng, attemptSeed); + last = dungeon; + if (this.isReachable(dungeon.map, dungeon.entrance, dungeon.exit, config)) { + return dungeon; + } + if (CONSOLE_LOGGING) { + console.warn(`[DungeonGenerator] attempt ${attempt}: exit unreachable from entrance, regenerating`); + } + } + console.error(`[DungeonGenerator] Could not generate a reachable dungeon after ${MAX_ATTEMPTS} attempts; returning last candidate.`); + return last; + } + + /** + * Single-pass dungeon generation (one attempt). Seeds ROT.RNG deterministically when + * `seedInt` is provided. Returns { map, rooms, entrance, exit, treasure, torches }. + */ + static _generateOnce(width, height, config, rng, seedInt) { + if (seedInt != null) { + ROT.RNG.setSeed(seedInt); } // Create dungeon using ROT.js Map.Digger @@ -142,32 +167,32 @@ class DungeonGenerator { corridorLength: DUNGEON_CONFIGS.CORRIDOR_LENGTH_RANGE, dugPercentage: DUNGEON_CONFIGS.DUG_PERCENTAGE }); - + // Initialize the empty map with walls (1) const map = Array(height).fill().map(() => Array(width).fill(1)); - + // Fill in floors (0) using digger's callback digger.create((x, y, value) => { // value: 0 = floor, 1 = wall map[y][x] = value; }); - + // Create enhanced map with floor variations and torches const enhancedMap = this.enhanceMapWithVariations(map, config); - + // Get rooms that were created const rooms = digger.getRooms(); - + // Place entrance in the first room const entranceRoom = rooms[0]; const entranceCenter = entranceRoom.getCenter(); const entrance = [entranceCenter[0], entranceCenter[1]]; - + // Place exit in the last room const exitRoom = rooms[rooms.length - 1]; const exitCenter = exitRoom.getCenter(); const exit = [exitCenter[0], exitCenter[1]]; - + // Place treasure in a middle room (not first or last) let treasureRoom; if (rooms.length > 2) { @@ -182,23 +207,56 @@ class DungeonGenerator { treasureCenter[0] + Math.floor(rng() * 3) - 1, treasureCenter[1] + Math.floor(rng() * 3) - 1 ]; - + // Ensure the treasure is on a floor tile (check for both floor types) const treasureTile = enhancedMap[treasure[1]][treasure[0]]; if (treasureTile !== config.primaryFloor && treasureTile !== config.secondaryFloor) { treasure[0] = treasureCenter[0]; treasure[1] = treasureCenter[1]; } - + return { map: enhancedMap, // Return the enhanced map with variations - rooms: rooms, + rooms: rooms, entrance: entrance, exit: exit, treasure: treasure, torches: this.getTorchPositions(enhancedMap, config) // Include torch positions }; } + + /** + * BFS flood-fill: is `to` reachable from `from` over walkable tiles? + */ + static isReachable(map, from, to, config) { + const primaryFloor = config.primaryFloor; + const secondaryFloor = config.secondaryFloor; + const isWalk = (t) => t === primaryFloor || t === secondaryFloor || t === 0 || t === '>' || t === '$M'; + const h = map.length; + const w = map[0].length; + const [sx, sy] = from; + const [tx, ty] = to; + if (!isWalk(map[sy] && map[sy][sx]) || !isWalk(map[ty] && map[ty][tx])) return false; + + const seen = Array.from({ length: h }, () => new Array(w).fill(false)); + let head = 0; + const queue = [[sx, sy]]; + seen[sy][sx] = true; + const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + while (head < queue.length) { + const [x, y] = queue[head++]; + if (x === tx && y === ty) return true; + for (const [dx, dy] of DIRS) { + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && nx < w && ny >= 0 && ny < h && !seen[ny][nx] && isWalk(map[ny][nx])) { + seen[ny][nx] = true; + queue.push([nx, ny]); + } + } + } + return false; + } // Enhanced map creation with floor variations and torch placement static enhanceMapWithVariations(basicMap, config) { diff --git a/src/game/game.js b/src/game/game.js index 3bd4b3e..bca4d8a 100644 --- a/src/game/game.js +++ b/src/game/game.js @@ -245,7 +245,7 @@ class Game { // Only move if accumulator >= 1 while (this.monsterMoveAccumulator >= 1) { this.monsterMoveAccumulator -= 1; - + // Apply chase aggressiveness (chance to move randomly instead of chasing). // Use the per-game seeded RNG so monster behaviour is deterministic from the seed. if (this.seededRNG() < chaseAggressiveness) { @@ -254,6 +254,21 @@ class Game { // Random move - still try to move, just not toward player this.monster.moveTowardPlayer(null, this.dungeon, this.seededRNG); } + + // Collision check after EACH sub-step (Phase 3.3). With movesPerPlayerMove > 1 + // (e.g. the casino preset's 1.5x speed) the monster takes multiple steps per turn; + // checking only after the loop let it step onto the player's tile and back off + // between checks, "phasing through" the player and missing the catch (a player- + // favorable bug). Checking each sub-step closes that gap. + if (this.monster.hasCaughtPlayer(this.player)) { + this.gameState = 'lost'; + return { + status: 'monster_caught', + event: 'monster_caught', + player: this.player.getState(), + monster: this.monster.getState() + }; + } } if (this.monster.hasCaughtPlayer(this.player)) { diff --git a/test/monsterCollision.test.js b/test/monsterCollision.test.js new file mode 100644 index 0000000..c74f324 --- /dev/null +++ b/test/monsterCollision.test.js @@ -0,0 +1,54 @@ +/** + * Monster collision tests (Phase 3.3). + * + * With movesPerPlayerMove > 1 (e.g. the casino preset's 1.5x speed) the monster takes + * multiple steps per turn. Collision must be checked after EACH sub-step, or the monster + * can step onto the player's tile and off again between checks, phasing through the player + * and missing the catch (a player-favorable bug that lowers the house edge). + */ + +const Game = require('../src/game/game'); + +function openGame() { + // Default-size dungeon so the digger generates validly; we then override the map and + // positions with a known fully-open grid so the scenario is deterministic. + const game = new Game('sock-test', { id: 1 }); + const FL = "'1"; + game.dungeon.map = Array.from({ length: 7 }, () => Array(7).fill(FL)); + game.gameConfig.primaryFloor = FL; + game.gameConfig.secondaryFloor = "'2"; + game.gameState = 'active'; + game.seededRNG = () => 0; // 0 < chaseAggressiveness => always chase + return game; +} + +describe('Monster collision with multi-step movement', () => { + test('2-step monster adjacent to the player catches it (no phasing through)', () => { + const game = openGame(); + game.difficultyConfig.monster.movesPerPlayerMove = 2; + game.difficultyConfig.monster.chaseAggressiveness = 1.0; + game.monsterMoveAccumulator = 0; + game.player.moveTo(3, 3); + game.monster.moveTo(3, 4); // directly adjacent + game.monster.lastKnownPlayerX = null; + game.monster.lastKnownPlayerY = null; + + const res = game.moveMonster(); + + expect(game.gameState).toBe('lost'); + expect(res.event).toBe('monster_caught'); + }); + + test('single-step monster moving onto the player still catches (sanity)', () => { + const game = openGame(); + game.difficultyConfig.monster.movesPerPlayerMove = 1; + game.difficultyConfig.monster.chaseAggressiveness = 1.0; + game.monsterMoveAccumulator = 0; + game.player.moveTo(3, 3); + game.monster.moveTo(3, 4); + + game.moveMonster(); + + expect(game.gameState).toBe('lost'); + }); +}); diff --git a/test/provablyFair.test.js b/test/provablyFair.test.js index 4eccf4c..96c8895 100644 --- a/test/provablyFair.test.js +++ b/test/provablyFair.test.js @@ -48,6 +48,29 @@ describe('Provably fair: deterministic dungeon generation', () => { }); }); +describe('Dungeon reachability (Phase 3.2)', () => { + test('generated dungeons always have a reachable exit (many seeds)', () => { + const cfg = { primaryFloor: "'1", secondaryFloor: "'2" }; + for (let i = 0; i < 25; i++) { + const seed = (i.toString(16).padStart(2, '0')).repeat(32); + const d = DungeonGenerator.regenerateFromSeed(seed, 'WOW'); + expect(DungeonGenerator.isReachable(d.map, d.entrance, d.exit, cfg)).toBe(true); + } + }); + + test('isReachable returns false when the target is walled off', () => { + const cfg = { primaryFloor: "'1", secondaryFloor: "'2" }; + // 3x3 all-floor map, but wall off the target corner. + const map = [ + ["'1", "'1", "'1"], + ["'1", "'1", '#'], + ["'1", '#', "'1"] // (2,2) is isolated by walls at (2,1) and (1,2) + ]; + expect(DungeonGenerator.isReachable(map, [0, 0], [2, 2], cfg)).toBe(false); + expect(DungeonGenerator.isReachable(map, [0, 0], [1, 0], cfg)).toBe(true); + }); +}); + describe('Provably fair: deterministic monster movement', () => { // A small open room so the monster always has somewhere to wander. function buildDungeon() {