Files
wownerogue/src/sim/simBots.js
T
jwinterm c5147de625 feat(game): multi-level dungeons — descend N levels, timer-driven edge
The fair, on-theme edge lever for slow chains (from the calibration work): a run
now descends `maxDepth` levels instead of one map, so cumulative run length makes
the block-timer bite WITHOUT a giant single dungeon or a cheating-fast monster.

Engine (game.js):
- depth/maxDepth; _generateLevel(depth) builds each level (fresh dungeon + monster
  + FOV), _descend() steps down. Reaching a non-final exit descends (event
  "descend", stays active); only the final exit escapes (event "escaped").
- Treasure lives only in the vault (final level) — intermediate levels race to the
  stairs. Single-level (maxDepth=1) is byte-identical to before.
- Provably fair per level: levelSeed(masterSeed, depth) (provablyFair.js) — level 1
  = master seed, deeper levels salted; the whole descent regenerates from the one
  committed seed. Verified: game levels == regenerateFromSeed(levelSeed(seed,d)).

Pacing (difficultyConfig.js): NETWORK_TUNING now sets LEVELS ∝ block time (GRIN 1,
XMR 2, LTC 2, WOW 4, BTC 8) — replacing the size-scale hack; size + monster stay at
the fair preset. DUNGEON_LEVELS operator override; NETWORK_TUNING_DISABLED → 1 level.

Wiring: socketHandlers afterPlayerMove gets a "descend" branch (no game-over, no
monster move on arrival). Client gameState.js clears the accumulated map/explored/
visible tiles on a depth change so the old level does not ghost through the new one.

Sim: explorer bot resets fog-of-war on descent so multi-level sims stay honest
(full re-calibration of the level counts is the pending follow-up).

Tests: multiLevel_ultra (descent, vault-only treasure, per-level provably-fair,
single-level default, DUNGEON_LEVELS) + networkTuning rewritten for the levels model.
Suite 61 suites / 432 green; end-to-end 4-level WOW run descends 3x then escapes.
2026-07-11 22:26:10 -04:00

158 lines
7.4 KiB
JavaScript

// Bot policies for the balance sim. A bot is a pure decision function: given the live Game, return
// the next move {dx,dy} (or null to give up). Bots NEVER mutate the game — the harness applies the
// move through the real engine. Two skill models bracket reality:
//
// omniscient — knows the full map from move 1; BFS-optimal to (treasure→)exit. This is the BEST
// case: it is a LOWER bound on completion time, hence a LOWER bound on house-win-rate.
// explorer — fog-of-war: only knows cells it has seen (radius `vision`). Explores toward the
// nearest frontier until the exit is revealed, then heads out. This is the realistic
// first-time-player model (every dungeon is seen fresh).
//
// Both softly avoid the monster's current + adjacent cells when a detour exists, so bots don't walk
// into obvious suicides — but they do NOT actively evade (a known bias: real skilled players evade
// better, so monster-deaths here are an UPPER bound). The omniscient/explorer pair plus this note
// bracket the true numbers honestly rather than pretending to a single "correct" bot.
const { bfsField, stepDownField } = require('./pathfind');
function floorAt(game, x, y) {
const m = game.dungeon.map;
if (!m[y] || m[y][x] === undefined) return false;
const v = m[y][x];
return v === game.gameConfig.primaryFloor || v === game.gameConfig.secondaryFloor;
}
// Cells to softly avoid: the monster and its 4 neighbours. Passed as a penalty set the BFS treats
// as impassable UNLESS the target itself sits there.
function monsterHazard(game) {
const s = new Set();
if (!game.monster) return s;
const { x, y } = game.monster;
for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) s.add((x + dx) + ',' + (y + dy));
return s;
}
// Build a passability predicate over floor cells, avoiding the monster hazard set (but always
// allowing the goal cell so a goal adjacent to the monster stays reachable).
function passableFactory(game, known, hazard, goalKey) {
return (x, y) => {
if (known && !known.has(x + ',' + y)) return false; // explorer: only through seen cells
if (!floorAt(game, x, y)) return false;
const k = x + ',' + y;
if (hazard.has(k) && k !== goalKey) return false;
return true;
};
}
function omniscientBot(opts = {}) {
const wantTreasure = opts.wantTreasure !== false;
return {
id: wantTreasure ? 'omniscient-greedy' : 'omniscient-escape',
vision: Infinity,
move(game) {
const cols = game.width, rows = game.height;
const hazard = monsterHazard(game);
const exit = game.dungeon.exit;
const treasure = game.dungeon.treasure; // null once collected
const goingTo = (wantTreasure && treasure && !game.player.hasTreasure) ? treasure : exit;
if (!goingTo) return null;
const goalKey = goingTo[0] + ',' + goingTo[1];
let passable = passableFactory(game, null, hazard, goalKey);
let field = bfsField(cols, rows, passable, [goingTo]);
let step = stepDownField(field, game.player.x, game.player.y, cols, rows);
if (step) return step;
// Blocked only by the monster hazard — retry ignoring it (accept the risk over stalling).
passable = passableFactory(game, null, new Set(), goalKey);
field = bfsField(cols, rows, passable, [goingTo]);
return stepDownField(field, game.player.x, game.player.y, cols, rows);
}
};
}
function explorerBot(opts = {}) {
const wantTreasure = opts.wantTreasure !== false;
const vision = opts.vision || 8;
const known = new Set(); // "x,y" of every cell ever seen (floor or wall)
const seenExit = { at: null };
const seenTreasure = { at: null };
let lastDepth = 1; // reset fog-of-war on a multi-level descent (fresh level)
function reveal(game) {
const px = game.player.x, py = game.player.y;
const r = vision;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > r * r) continue;
const x = px + dx, y = py + dy;
if (y < 0 || x < 0 || y >= game.height || x >= game.width) continue;
known.add(x + ',' + y);
}
}
const ex = game.dungeon.exit;
if (ex && known.has(ex[0] + ',' + ex[1])) seenExit.at = ex;
const tr = game.dungeon.treasure;
if (tr && known.has(tr[0] + ',' + tr[1])) seenTreasure.at = tr;
}
// Frontier = a known floor cell adjacent to an unknown cell (the edge of the explored region).
function frontiers(game) {
const out = [];
for (const key of known) {
const [x, y] = key.split(',').map(Number);
if (!floorAt(game, x, y)) continue;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
if (!known.has((x + dx) + ',' + (y + dy))) { out.push([x, y]); break; }
}
}
return out;
}
return {
id: wantTreasure ? 'explorer-greedy' : 'explorer-escape',
vision,
move(game) {
// On a descent, the old level's knowledge is stale — start the new level unexplored.
if (game.depth && game.depth !== lastDepth) {
known.clear(); seenExit.at = null; seenTreasure.at = null; lastDepth = game.depth;
}
reveal(game);
const cols = game.width, rows = game.height;
const hazard = monsterHazard(game);
// Goal priority: known treasure (if wanted & uncollected) → known exit → nearest frontier.
let goal = null;
if (wantTreasure && seenTreasure.at && !game.player.hasTreasure) goal = seenTreasure.at;
else if (seenExit.at && (!wantTreasure || game.player.hasTreasure || !seenTreasure.at)) goal = seenExit.at;
if (goal) {
const goalKey = goal[0] + ',' + goal[1];
const passable = passableFactory(game, known, hazard, goalKey);
const field = bfsField(cols, rows, passable, [goal]);
const step = stepDownField(field, game.player.x, game.player.y, cols, rows);
if (step) return step;
}
// Explore: head to the nearest frontier through known floor.
const fs = frontiers(game);
if (fs.length) {
const passable = passableFactory(game, known, hazard, null);
const field = bfsField(cols, rows, passable, fs);
const step = stepDownField(field, game.player.x, game.player.y, cols, rows);
if (step) return step;
// Hazard-blocked — retry without avoiding the monster.
const field2 = bfsField(cols, rows, passableFactory(game, known, new Set(), null), fs);
const step2 = stepDownField(field2, game.player.x, game.player.y, cols, rows);
if (step2) return step2;
}
return null;
}
};
}
const BOTS = {
'omniscient-escape': () => omniscientBot({ wantTreasure: false }),
'omniscient-greedy': () => omniscientBot({ wantTreasure: true }),
'explorer-escape': (o) => explorerBot({ ...o, wantTreasure: false }),
'explorer-greedy': (o) => explorerBot({ ...o, wantTreasure: true })
};
module.exports = { BOTS, omniscientBot, explorerBot };