mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 07:38:34 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user