fix: dungeon reachability guard + per-substep monster collision

3.2 Reachability: a dungeon whose exit is unreachable from the entrance is
paid-but-unwinnable. generate() now BFS-checks entrance->exit and regenerates
if unreachable. Retries are deterministic (per-attempt ROT seed = seedInt +
attempt; the seeded rng stream keeps advancing), so verify-regeneration
replays the same attempts and reproduces the exact dungeon — the provably-fair
determinism from Phase 0.2 is preserved. The single-pass body is extracted to
_generateOnce and isReachable does the flood-fill.

3.3 Monster collision: with movesPerPlayerMove > 1 (the casino preset's 1.5x
speed) the monster takes multiple steps per turn, but collision was only
checked after the loop — so it could step onto the player's tile and off again
between checks, phasing through and missing the catch (player-favorable, erodes
the house edge). Collision is now checked after each sub-step.

Adds reachability tests (provablyFair.test.js) and monsterCollision.test.js.
Random block timing remains the untouched core mechanic. Full suite: 20 suites
/ 153 tests.
This commit is contained in:
jwinterm
2026-05-31 14:08:45 -04:00
parent d2ff9c27ba
commit 7d3456f107
5 changed files with 172 additions and 22 deletions
+2 -2
View File
@@ -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`.
+77 -19
View File
@@ -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) {
+16 -1
View File
@@ -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)) {
+54
View File
@@ -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');
});
});
+23
View File
@@ -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() {