mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-25 01:58:33 +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:
+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)) {
|
||||
|
||||
Reference in New Issue
Block a user