mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
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:
@@ -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
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user