mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 07:08:33 +02:00
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.
158 lines
7.4 KiB
JavaScript
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 };
|