feat(balance): per-network difficulty tuning — cryptoType finally shapes size+monster

Solves the two gaps the sim measured (edge undershot targets AND swung ~15pts
across chains from identical dungeons because cryptoType was a dead parameter).

Data-driven design (src/sim/calibrate.js, rewritten): the size-probe showed
dungeon SIZE self-cancels as a house-edge lever — bigger map lengthens the run
(timer edge up) but gives more room to dodge (monster edge down), flat ~60% until
monstrous. So split the concerns:
  • SIZE ∝ √blockTime  → PACING only (consistent run-length fraction of the block;
    GRIN sprint .. BTC epic), applied to every preset. Bot-robust.
  • MONSTER speed       → the house EDGE lever (monotonic), solved per network for
    the casino/70% target only.

difficultyConfig.js: NETWORK_TUNING table + applyNetworkTuning(), folded into
getDifficultyConfig with precedence preset → tuning → env → customOverrides (so
operator DUNGEON_*/MONSTER_* still win; NETWORK_TUNING_DISABLED kills it).

Wired verification (explorer bot, 160 runs, 320ms): WOW/XMR/LTC/BTC/GRIN all land
in a ~66-76% band around target (was 48-65%). BTC 48%->73% via 112x56 + 2.2x
monster — on 10-min blocks the timer barely bites a 30s run, so the monster carries
the edge (caps ~64% maxed: a real property of block-timed play).

CAVEAT baked into the config comment: sim bots do not evade, so monsterSpeed is a
STARTING POINT to retune with live telemetry; the sizeScale half is bot-robust.
Deb is unaffected (WOW=reference scale 1.0; normal preset gets no monster tuning).

test/networkTuning_ultra.test.js: size scaling, casino-only monster, block-time
ordering, env-override-wins, kill-switch. Suite 60 suites / 428 tests green.
This commit is contained in:
jwinterm
2026-07-11 20:02:39 -04:00
parent fd4327a54f
commit 63d2f5281d
4 changed files with 233 additions and 3 deletions
+43 -2
View File
@@ -144,12 +144,51 @@ function getDifficultyPreset(cryptoType = 'WOW', overridePreset = null) {
return { ...DIFFICULTY_PRESETS.casino, presetName: 'casino' };
}
// Per-network difficulty tuning, SOLVED by the balance sim (src/sim/calibrate.js) so cryptoType
// finally shapes difficulty instead of being a dead parameter. Two independent levers:
// sizeScale ∝ √(blockTime/ref) — PACING: keeps a run's length a consistent fraction of the block
// interval (GRIN sprint … BTC epic), applied to EVERY preset; the timer then contributes a
// stable baseline. Reference network = WOW/XMR (2-min blocks) = 1.0.
// monsterSpeed (movesPerPlayerMove) — the house EDGE lever; calibrated for the CASINO preset
// (70% target) only, so it's applied ONLY to casino. On slow chains (BTC) the timer barely bites
// a ~30s run, so the monster carries the edge (and caps ~64% even maxed — a real property of
// block-timed play, not a bug).
// CAVEAT: the sim bots don't actively evade the monster, so monsterSpeed is a STARTING POINT that
// under-provisions vs skilled humans — validate/retune with live telemetry. The sizeScale half is
// bot-robust. Operator env overrides (DUNGEON_*, MONSTER_*) still win. Kill-switch: NETWORK_TUNING_DISABLED=true.
const NETWORK_TUNING = {
WOW: { sizeScale: 1.00, monsterSpeed: 1.08 },
XMR: { sizeScale: 1.00, monsterSpeed: 1.08 },
LTC: { sizeScale: 1.12, monsterSpeed: 1.11 },
BTC: { sizeScale: 1.60, monsterSpeed: 2.20 },
GRIN: { sizeScale: 0.71, monsterSpeed: 0.94 }
};
// Fold the per-network tuning into a resolved preset (size for all presets, monster for casino).
function applyNetworkTuning(preset, cryptoType) {
if (process.env.NETWORK_TUNING_DISABLED === 'true') return preset;
const t = NETWORK_TUNING[String(cryptoType || '').trim().toUpperCase()];
if (!t) return preset;
const out = {
...preset,
dungeon: {
...preset.dungeon,
width: Math.max(20, Math.round(preset.dungeon.width * t.sizeScale)),
height: Math.max(12, Math.round(preset.dungeon.height * t.sizeScale))
}
};
if (preset.presetName === 'casino' && t.monsterSpeed) {
out.monster = { ...preset.monster, movesPerPlayerMove: t.monsterSpeed };
}
return out;
}
/**
* Merge difficulty config with custom overrides.
* Environment variables override preset values when set.
* Precedence (low→high): preset → per-network tuning → env vars → explicit customOverrides.
*/
function getDifficultyConfig(cryptoType = 'WOW', customOverrides = {}) {
const preset = getDifficultyPreset(cryptoType, customOverrides.preset);
const preset = applyNetworkTuning(getDifficultyPreset(cryptoType, customOverrides.preset), cryptoType);
// Apply env var overrides (only when explicitly set)
const envOverrides = {
@@ -217,6 +256,8 @@ function getTreasureRoomIndex(rooms, positionRatio = 0.5) {
module.exports = {
DIFFICULTY_PRESETS,
NETWORK_TUNING,
applyNetworkTuning,
getDifficultyPreset,
getDifficultyConfig,
getMonsterSpawnRoomIndex,
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
// Per-network difficulty calibrator (v2), rebuilt around what the size-probe revealed:
//
// Dungeon SIZE trades one edge-source for another — a bigger map lengthens the run (timer edge
// UP) but gives the player more room to dodge (monster edge DOWN), so house-win self-cancels
// around ~60% until you go monstrous (210x105, 650-move slogs). Size is therefore NOT a clean
// house-edge lever.
//
// So we split the two concerns:
// • SIZE ∝ √(blockTime) — pure PACING. A run's completion time stays a consistent fraction of
// the block interval, so it feels chain-appropriate (GRIN sprint … BTC epic) and the timer
// contributes a stable baseline. Clamped to stay playable.
// • MONSTER speed — the house EDGE lever (monotonic: meaner monster → more catches),
// solved per network to hit the target house-win at the pacing size.
//
// CAVEAT: the bots don't actively evade the monster, so the solved monster-speed is a STARTING
// POINT that under-provisions vs skilled humans — validate/retune the monster with real telemetry.
// The SIZE∝blockTime half is bot-robust (it keys off completion TIME, which the explorer models).
//
// USAGE: node src/sim/calibrate.js [--target 0.70] [--preset casino] [--runs 160] [--cadence 320]
const { runOneGame } = require('./simulate');
const { BOTS } = require('./simBots');
const { meanBlockTimeMsFor } = require('../chain/chainProfile');
const { DIFFICULTY_PRESETS } = require('../game/difficultyConfig');
const BASE_BLOCK_MS = 120000; // WOW/XMR — the reference the size scale is relative to
function parseArgs(argv) {
const a = { target: 0.70, preset: 'casino', bot: 'explorer-greedy', runs: 160, cadence: 320,
nets: ['WOW', 'XMR', 'LTC', 'BTC', 'GRIN'], moveCap: 5000,
sizeMin: 0.7, sizeMax: 1.6, spdMin: 0.6, spdMax: 2.2 };
for (let i = 2; i < argv.length; i++) {
const t = argv[i];
if (t === '--target') a.target = parseFloat(argv[++i]);
else if (t === '--preset') a.preset = argv[++i];
else if (t === '--bot') a.bot = argv[++i];
else if (t === '--runs') a.runs = parseInt(argv[++i], 10);
else if (t === '--cadence') a.cadence = parseInt(argv[++i], 10);
else if (t === '--nets') a.nets = argv[++i].split(',');
}
return a;
}
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
// Pacing size multiplier: √(blockTime / reference), clamped to a playable band.
function pacingScale(net, args) {
return clamp(Math.sqrt(meanBlockTimeMsFor(net) / BASE_BLOCK_MS), args.sizeMin, args.sizeMax);
}
// Measure house-win (+ timer/monster split) at a given size and monster speed on one network.
function measure(preset, net, width, height, monsterSpeed, args) {
process.env.DIFFICULTY_PRESET = preset;
process.env.PAYMENTS_ENABLED = 'false';
process.env.MONSTER_SPEED = String(monsterSpeed);
const bt = meanBlockTimeMsFor(net);
const factory = BOTS[args.bot];
let house = 0, timer = 0, caught = 0, escaped = 0, moveSum = 0;
for (let i = 0; i < args.runs; i++) {
const r = runOneGame(factory, { vision: 8, moveCap: args.moveCap, gameOptions: { width, height } });
if (r.outcome === 'escaped') {
const tOut = 1 - Math.exp(-(r.moves * args.cadence) / bt);
house += tOut; timer += tOut; escaped++; moveSum += r.moves;
} else { house += 1; caught += (r.outcome === 'caught' ? 1 : 0); }
}
return { house: house / args.runs, timerShare: timer / args.runs, monsterShare: caught / args.runs,
escapeRate: escaped / args.runs, medMovesEscaped: escaped ? Math.round(moveSum / escaped) : NaN };
}
// Binary-search monster speed (monotonic in house-win) to the target, at the pacing size.
function solve(preset, net, args) {
const base = DIFFICULTY_PRESETS[preset].dungeon;
const scale = pacingScale(net, args);
const width = Math.max(20, Math.round(base.width * scale));
const height = Math.max(12, Math.round(base.height * scale));
let lo = args.spdMin, hi = args.spdMax, best = null, flag = '';
const loM = measure(preset, net, width, height, lo, args);
if (loM.house >= args.target) { best = loM; best.speed = lo; flag = 'floored (min monster already ≥ target)'; }
else {
const hiM = measure(preset, net, width, height, hi, args);
if (hiM.house < args.target) { best = hiM; best.speed = hi; flag = 'CAPPED (max monster < target)'; }
else {
for (let it = 0; it < 8; it++) {
const mid = (lo + hi) / 2;
const m = measure(preset, net, width, height, mid, args);
best = m; best.speed = mid;
if (m.house < args.target) lo = mid; else hi = mid;
}
}
}
return { net, scale, width, height, speed: best.speed, ...best, flag };
}
function pct(x) { return isNaN(x) ? ' -- ' : (x * 100).toFixed(1).padStart(5) + '%'; }
function main() {
const args = parseArgs(process.argv);
delete process.env.MONSTER_SPEED;
const line = '─'.repeat(98);
const base = DIFFICULTY_PRESETS[args.preset].dungeon;
console.log('\nPer-network calibration → target house-win %s preset=%s (base %dx%d)',
(args.target * 100).toFixed(0) + '%', args.preset, base.width, base.height);
console.log('SIZE ∝ √blockTime (pacing); MONSTER speed solved for edge. bot=%s runs/eval=%d cadence=%dms',
args.bot, args.runs, args.cadence);
console.log(line);
console.log(['net'.padEnd(5), 'block', 'dims'.padEnd(9), 'mSpeed', 'house', '(timer', 'monst)', 'esc%', 'note'].join(' '));
console.log(line);
const table = {};
for (const net of args.nets) {
const s = solve(args.preset, net, args);
table[net] = { width: s.width, height: s.height, monsterSpeed: Math.round(s.speed * 100) / 100 };
console.log([
net.padEnd(5), (meanBlockTimeMsFor(net) / 60000 + 'm').padStart(5), `${s.width}x${s.height}`.padEnd(9),
s.speed.toFixed(2).padStart(6), pct(s.house), pct(s.timerShare), pct(s.monsterShare) + ')',
pct(s.escapeRate), s.flag
].join(' '));
}
console.log(line);
console.log('\nSolved NETWORK_TUNING (paste into difficultyConfig):');
console.log(JSON.stringify(table, null, 0));
console.log('\nmSpeed = monster movesPerPlayerMove. timer/monst = share of house-win from the');
console.log('block deadline vs from being caught. CAVEAT: bots don\'t evade → monster speed is a');
console.log('starting point; the SIZE column is the bot-robust part. Re-run with real cadence.\n');
}
if (require.main === module) main();
module.exports = { measure, solve, pacingScale };
+1 -1
View File
@@ -47,7 +47,7 @@ function parseArgs(argv) {
// 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 game = new Game('sim', { id: 0, username: 'sim' }, opts.gameOptions || {});
const bot = botFactory({ vision: opts.vision });
let lastX = -1, lastY = -1, stallTicks = 0, invalidStreak = 0;
while (game.gameState === 'active' && game.moveCount < opts.moveCap) {
+61
View File
@@ -0,0 +1,61 @@
// Guards the per-network difficulty tuning: cryptoType must shape size (all presets) and monster
// (casino only), block-time ordering must hold, and operator env overrides must still win.
const {
DIFFICULTY_PRESETS, NETWORK_TUNING, applyNetworkTuning, getDifficultyConfig
} = require('../src/game/difficultyConfig');
const casino = () => ({ ...DIFFICULTY_PRESETS.casino, presetName: 'casino' });
const normal = () => ({ ...DIFFICULTY_PRESETS.normal, presetName: 'normal' });
// Isolate env mutations per test.
const SAVE = {};
const KEYS = ['NETWORK_TUNING_DISABLED', 'DUNGEON_WIDTH', 'MONSTER_SPEED', 'DIFFICULTY_PRESET', 'CRYPTO_TYPE', 'PAYMENTS_ENABLED', 'GAME_MODE'];
beforeEach(() => { KEYS.forEach(k => { SAVE[k] = process.env[k]; delete process.env[k]; }); });
afterEach(() => { KEYS.forEach(k => { if (SAVE[k] === undefined) delete process.env[k]; else process.env[k] = SAVE[k]; }); });
describe('per-network difficulty tuning', () => {
test('WOW is the reference (sizeScale 1.0) — casino dims unchanged', () => {
const t = applyNetworkTuning(casino(), 'WOW');
expect(t.dungeon.width).toBe(DIFFICULTY_PRESETS.casino.dungeon.width);
expect(t.dungeon.height).toBe(DIFFICULTY_PRESETS.casino.dungeon.height);
});
test('dungeon size scales with block time: GRIN < WOW < BTC', () => {
const g = applyNetworkTuning(casino(), 'GRIN').dungeon.width;
const w = applyNetworkTuning(casino(), 'WOW').dungeon.width;
const b = applyNetworkTuning(casino(), 'BTC').dungeon.width;
expect(g).toBeLessThan(w);
expect(w).toBeLessThan(b);
});
test('monster speed is tuned for casino but NOT for other presets', () => {
expect(applyNetworkTuning(casino(), 'BTC').monster.movesPerPlayerMove).toBe(NETWORK_TUNING.BTC.monsterSpeed);
// normal keeps its own monster speed (tuning was calibrated for casino only)
expect(applyNetworkTuning(normal(), 'BTC').monster.movesPerPlayerMove)
.toBe(DIFFICULTY_PRESETS.normal.monster.movesPerPlayerMove);
// ...but normal STILL gets the pacing size scale
expect(applyNetworkTuning(normal(), 'BTC').dungeon.width).toBeGreaterThan(DIFFICULTY_PRESETS.normal.dungeon.width);
});
test('unknown network is left untouched', () => {
const t = applyNetworkTuning(casino(), 'DOGE');
expect(t.dungeon.width).toBe(DIFFICULTY_PRESETS.casino.dungeon.width);
});
test('operator env overrides win over tuning', () => {
process.env.DIFFICULTY_PRESET = 'casino';
process.env.DUNGEON_WIDTH = '99';
process.env.MONSTER_SPEED = '1.0';
const cfg = getDifficultyConfig('BTC');
expect(cfg.dungeon.width).toBe(99); // env beats the 1.6× scale
expect(cfg.monster.movesPerPlayerMove).toBe(1.0); // env beats the 2.2 tuned speed
});
test('NETWORK_TUNING_DISABLED=true reverts to the raw preset', () => {
process.env.NETWORK_TUNING_DISABLED = 'true';
process.env.DIFFICULTY_PRESET = 'casino';
const cfg = getDifficultyConfig('BTC');
expect(cfg.dungeon.width).toBe(DIFFICULTY_PRESETS.casino.dungeon.width);
expect(cfg.monster.movesPerPlayerMove).toBe(DIFFICULTY_PRESETS.casino.monster.movesPerPlayerMove);
});
});