feat(sim): headless balance-calibration harness (drives the REAL engine)

The instrument the design discussion said was missing. src/sim/ drives the actual
game engine (game.js — same dungeon gen, monster AI, movement as prod) with
headless bots to terminal outcomes, then measures what was only ever DECLARED:

- Per preset: escape/treasure/caught/stuck rates + completion-move/-time pctiles.
- MEASURED house-win-rate per network, integrating out the random block deadline
  analytically: for D~Exp(mean=blockTime), P(house wins an escape at T)=1-exp(-T/BT),
  so no Monte-Carlo over deadlines.
- Two bot skill models (omniscient beeline / explorer fog-of-war) as an upper band;
  neither evades the monster, so true house-win is likely below both.

First findings (explorer, 200 runs, 320ms/move): presets UNDERSHOOT their declared
targets (casino 57% vs 70%, normal 47% vs 55% on WOW) and the edge swings ~15pts
across chains from IDENTICAL dungeons (casino 48% BTC .. 65% GRIN) — because
cryptoType never actually sizes the dungeon. Exactly the two gaps we predicted.

Files: src/sim/{pathfind,simBots,simulate}.js + test/sim_ultra.test.js (structural
guard, not a balance assertion). Run: node src/sim/simulate.js --help-ish flags.
Suite 59/422 green.
This commit is contained in:
jwinterm
2026-07-11 19:43:12 -04:00
parent 11de46a7e4
commit fd4327a54f
4 changed files with 418 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
// Grid BFS used by the balance-sim bots. Kept dependency-free (no ROT) so the sim is a thin,
// auditable layer over the REAL game engine — it never reimplements game rules, only navigation.
// Breadth-first distance field from one or more source cells over a passability predicate.
// Returns a rows×cols Int32Array-of-arrays where unreachable = -1. 4-connected (the game moves
// in cardinal directions only).
function bfsField(cols, rows, passable, sources) {
const dist = [];
for (let y = 0; y < rows; y++) dist.push(new Array(cols).fill(-1));
const q = [];
for (const [sx, sy] of sources) {
if (sy >= 0 && sy < rows && sx >= 0 && sx < cols && dist[sy][sx] === -1) {
dist[sy][sx] = 0;
q.push([sx, sy]);
}
}
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
let head = 0;
while (head < q.length) {
const [x, y] = q[head++];
const d = dist[y][x];
for (const [dx, dy] of DIRS) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
if (dist[ny][nx] !== -1) continue;
if (!passable(nx, ny)) continue;
dist[ny][nx] = d + 1;
q.push([nx, ny]);
}
}
return dist;
}
// Given a distance field (to a target), return the cardinal step {dx,dy} that most reduces
// distance from (px,py), or null if there's no downhill neighbour (unreachable / already there).
function stepDownField(dist, px, py, cols, rows) {
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const here = dist[py] && dist[py][px];
if (here == null || here < 0) return null;
let best = null, bestD = here;
for (const [dx, dy] of DIRS) {
const nx = px + dx, ny = py + dy;
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
const nd = dist[ny][nx];
if (nd >= 0 && nd < bestD) { bestD = nd; best = { dx, dy }; }
}
return best;
}
module.exports = { bfsField, stepDownField };
+152
View File
@@ -0,0 +1,152 @@
// 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 };
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) {
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 };
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
// Balance-calibration harness for Wownerogue.
//
// WHY THIS EXISTS: dungeon difficulty presets each *declare* a targetHouseWinRate, but nothing ever
// MEASURED it, and single-player runs are bounded by a RANDOM block deadline (D ~ Exp(mean = the
// network's block time)). So the real house edge is a convolution of the completion-time
// distribution with the network's block cadence — un-guessable by hand, and different on every
// chain even though dungeon size is currently identical across chains.
//
// WHAT IT DOES: drives the REAL engine (src/game/game.js — same dungeon generator, monster AI and
// movement as production) with headless bots to many terminal outcomes, then reports, per preset:
// • escape / treasure / caught / stuck rates
// • completion-move and completion-time percentiles
// • MEASURED house-win-rate for each network, vs the preset's declared target
//
// THE KEY IDENTITY: for a run that escapes at time T, P(a block lands first) = exp(-T / blockTime),
// so we integrate the random deadline out analytically instead of Monte-Carlo'ing it:
// house_win_rate(net) = mean over runs of [ caught|stuck ? 1 : exp(-T_escape / meanBlockTime(net)) ]
//
// USAGE:
// node src/sim/simulate.js [--runs 200] [--bot explorer-greedy] [--presets normal,casino]
// [--nets WOW,XMR,LTC,BTC,GRIN] [--cadence 320] [--json]
// Bots: omniscient-escape | omniscient-greedy | explorer-escape | explorer-greedy (default).
const Game = require('../game/game');
const { BOTS } = require('./simBots');
const { DIFFICULTY_PRESETS } = require('../game/difficultyConfig');
const { meanBlockTimeMsFor, PROFILES } = require('../chain/chainProfile');
function parseArgs(argv) {
const a = { runs: 200, bot: 'explorer-greedy', presets: ['easy', 'normal', 'hard', 'casino'],
nets: ['WOW', 'XMR', 'LTC', 'BTC', 'GRIN'], cadence: 320, json: false, moveCap: 6000 };
for (let i = 2; i < argv.length; i++) {
const t = argv[i];
if (t === '--runs') a.runs = parseInt(argv[++i], 10);
else if (t === '--bot') a.bot = argv[++i];
else if (t === '--presets') a.presets = argv[++i].split(',');
else if (t === '--nets') a.nets = argv[++i].split(',');
else if (t === '--cadence') a.cadence = parseInt(argv[++i], 10);
else if (t === '--movecap') a.moveCap = parseInt(argv[++i], 10);
else if (t === '--json') a.json = true;
}
return a;
}
// Drive ONE game to a terminal outcome with the real engine. Returns {outcome, moves, treasure}.
// outcome: 'escaped' (reached exit) | 'caught' (monster) | 'stuck' (bot gave up / hit move cap —
// treated as a timeout, i.e. a house win, since the player never got out).
function runOneGame(botFactory, opts) {
const game = new Game('sim', { id: 0, username: 'sim' }, {});
const bot = botFactory({ vision: opts.vision });
let lastX = -1, lastY = -1, stallTicks = 0, invalidStreak = 0;
while (game.gameState === 'active' && game.moveCount < opts.moveCap) {
const step = bot.move(game);
if (!step) break; // bot has no reachable goal/frontier → stuck
const res = game.movePlayer(step.dx, step.dy);
if (res.status === 'invalid') {
if (++invalidStreak > 8) break; // pathfinder wedged against geometry → bail
continue;
}
invalidStreak = 0;
if (game.gameState === 'won') return { outcome: 'escaped', moves: game.moveCount, treasure: !!game.player.hasTreasure };
if (game.gameState === 'lost') return { outcome: 'caught', moves: game.moveCount, treasure: !!game.player.hasTreasure };
game.moveMonster();
if (game.gameState === 'lost') return { outcome: 'caught', moves: game.moveCount, treasure: !!game.player.hasTreasure };
// Stall guard: bot orbiting the same cell (BFS tie / hazard bounce) makes no net progress.
if (game.player.x === lastX && game.player.y === lastY) { if (++stallTicks > 12) break; }
else { stallTicks = 0; lastX = game.player.x; lastY = game.player.y; }
}
return { outcome: 'stuck', moves: game.moveCount, treasure: !!game.player.hasTreasure };
}
function pct(sorted, p) {
if (!sorted.length) return NaN;
const i = Math.min(sorted.length - 1, Math.floor(p * (sorted.length - 1)));
return sorted[i];
}
function mean(xs) { return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN; }
function simulatePreset(preset, args) {
process.env.DIFFICULTY_PRESET = preset;
process.env.PAYMENTS_ENABLED = 'false';
const botFactory = BOTS[args.bot];
if (!botFactory) throw new Error('unknown bot: ' + args.bot + ' (have: ' + Object.keys(BOTS).join(', ') + ')');
const runs = [];
for (let i = 0; i < args.runs; i++) runs.push(runOneGame(botFactory, { vision: 8, moveCap: args.moveCap }));
const escaped = runs.filter(r => r.outcome === 'escaped');
const caught = runs.filter(r => r.outcome === 'caught');
const stuck = runs.filter(r => r.outcome === 'stuck');
const withTreasure = escaped.filter(r => r.treasure);
const escMoves = escaped.map(r => r.moves).sort((a, b) => a - b);
const escSecs = escMoves.map(m => (m * args.cadence) / 1000);
// Measured house-win-rate per network via the analytic deadline identity.
const houseWin = {};
for (const net of args.nets) {
const bt = meanBlockTimeMsFor(net);
// Escaped at time T survives iff the block lands AFTER T: P(survive)=exp(-T/bt), so the
// house wins that run with prob 1-exp(-T/bt). Caught/stuck → house wins outright (=1).
const contrib = runs.map(r => r.outcome === 'escaped'
? (1 - Math.exp(-(r.moves * args.cadence) / bt))
: 1);
houseWin[net] = mean(contrib);
}
return {
preset,
dims: `${DIFFICULTY_PRESETS[preset].dungeon.width}x${DIFFICULTY_PRESETS[preset].dungeon.height}`,
target: DIFFICULTY_PRESETS[preset].targetHouseWinRate,
n: runs.length,
escapeRate: escaped.length / runs.length,
treasureRate: withTreasure.length / runs.length,
caughtRate: caught.length / runs.length,
stuckRate: stuck.length / runs.length,
escMoveP50: pct(escMoves, 0.5), escMoveP90: pct(escMoves, 0.9),
escSecP50: pct(escSecs, 0.5), escSecP90: pct(escSecs, 0.9),
houseWin
};
}
function fmtPct(x) { return (isNaN(x) ? ' -- ' : (x * 100).toFixed(1).padStart(5) + '%'); }
function fmtNum(x) { return isNaN(x) ? ' --' : String(Math.round(x)).padStart(4); }
function report(results, args) {
const line = '─'.repeat(96);
console.log('\nWownerogue balance sim — bot=%s runs/preset=%d cadence=%dms/move', args.bot, args.runs, args.cadence);
console.log('Block times (min): ' + args.nets.map(n => `${n} ${(meanBlockTimeMsFor(n) / 60000)}`).join(' '));
console.log(line);
console.log(['preset'.padEnd(8), 'dims'.padEnd(8), 'esc%', 'trs%', 'cgt%', 'stk%',
'escMv50', 'escS50', 'escS90'].join(' '));
console.log(line);
for (const r of results) {
console.log([
r.preset.padEnd(8), r.dims.padEnd(8), fmtPct(r.escapeRate), fmtPct(r.treasureRate),
fmtPct(r.caughtRate), fmtPct(r.stuckRate), fmtNum(r.escMoveP50).padStart(7),
(isNaN(r.escSecP50) ? '--' : r.escSecP50.toFixed(0) + 's').padStart(6),
(isNaN(r.escSecP90) ? '--' : r.escSecP90.toFixed(0) + 's').padStart(6)
].join(' '));
}
console.log(line);
console.log('\nMEASURED house-win-rate by network (declared target in [brackets]):');
console.log(line);
console.log(['preset'.padEnd(8), 'target', ...args.nets.map(n => n.padStart(6))].join(' '));
console.log(line);
for (const r of results) {
console.log([
r.preset.padEnd(8),
('[' + (r.target * 100).toFixed(0) + '%]').padStart(6),
...args.nets.map(n => fmtPct(r.houseWin[n]))
].join(' '));
}
console.log(line);
console.log('esc%=escaped trs%=escaped WITH treasure cgt%=monster caught stk%=never got out');
console.log('escMv50=median moves to escape escS50/90=escape seconds p50/p90 (moves×cadence)');
console.log('Note: NEITHER bot actively evades the monster, so both OVER-count catches — a');
console.log('skilled evading human wins more, so real house-win is likely BELOW these numbers.');
console.log('explorer-* wanders (dodges the monster by luck, slower); omniscient-* beelines');
console.log('(faster but walks into the monster). Read them as an upper band, not a tight bracket.\n');
}
function main() {
const args = parseArgs(process.argv);
const results = args.presets.map(p => simulatePreset(p, args));
if (args.json) { console.log(JSON.stringify({ args, results }, null, 2)); return; }
report(results, args);
}
if (require.main === module) main();
module.exports = { runOneGame, simulatePreset };
+46
View File
@@ -0,0 +1,46 @@
// Guards the balance-sim harness: it must keep driving the REAL engine to terminal outcomes and
// producing well-formed, in-range statistics. Not a balance assertion (those numbers are meant to
// move) — a structural regression guard so the instrument can't silently rot.
const { runOneGame, simulatePreset } = require('../src/sim/simulate');
const { BOTS } = require('../src/sim/simBots');
const { bfsField, stepDownField } = require('../src/sim/pathfind');
describe('balance sim harness', () => {
test('BFS field + downhill step navigate a trivial grid', () => {
const passable = () => true;
const dist = bfsField(3, 3, passable, [[2, 2]]);
expect(dist[2][2]).toBe(0);
expect(dist[0][0]).toBe(4); // Manhattan distance, 4-connected
const step = stepDownField(dist, 0, 0, 3, 3);
expect(step).toEqual(expect.objectContaining({ dx: expect.any(Number), dy: expect.any(Number) }));
// Step must reduce distance to the target.
expect(dist[0 + step.dy][0 + step.dx]).toBeLessThan(dist[0][0]);
});
test('every bot policy drives a game to a real terminal outcome', () => {
for (const id of Object.keys(BOTS)) {
const r = runOneGame(BOTS[id], { vision: 8, moveCap: 6000 });
expect(['escaped', 'caught', 'stuck']).toContain(r.outcome);
expect(r.moves).toBeGreaterThanOrEqual(0);
expect(typeof r.treasure).toBe('boolean');
}
});
test('simulatePreset returns in-range rates and per-network house-win', () => {
const args = { runs: 15, bot: 'explorer-greedy', nets: ['WOW', 'BTC', 'GRIN'], cadence: 320, moveCap: 6000 };
const r = simulatePreset('normal', args);
expect(r.n).toBe(15);
for (const k of ['escapeRate', 'treasureRate', 'caughtRate', 'stuckRate']) {
expect(r[k]).toBeGreaterThanOrEqual(0);
expect(r[k]).toBeLessThanOrEqual(1);
}
// Outcomes partition the runs.
expect(r.escapeRate + r.caughtRate + r.stuckRate).toBeCloseTo(1, 5);
for (const net of args.nets) {
expect(r.houseWin[net]).toBeGreaterThanOrEqual(0);
expect(r.houseWin[net]).toBeLessThanOrEqual(1);
}
// Slower blocks (BTC 10min) give players more time → house wins no MORE than on fast GRIN.
expect(r.houseWin.BTC).toBeLessThanOrEqual(r.houseWin.GRIN + 1e-9);
});
});