mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user