diff --git a/src/sim/pathfind.js b/src/sim/pathfind.js new file mode 100644 index 0000000..381f555 --- /dev/null +++ b/src/sim/pathfind.js @@ -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 }; diff --git a/src/sim/simBots.js b/src/sim/simBots.js new file mode 100644 index 0000000..3b81cae --- /dev/null +++ b/src/sim/simBots.js @@ -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 }; diff --git a/src/sim/simulate.js b/src/sim/simulate.js new file mode 100644 index 0000000..9a32e51 --- /dev/null +++ b/src/sim/simulate.js @@ -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 }; diff --git a/test/sim_ultra.test.js b/test/sim_ultra.test.js new file mode 100644 index 0000000..e350b72 --- /dev/null +++ b/test/sim_ultra.test.js @@ -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); + }); +});