diff --git a/html/js/core/gameState.js b/html/js/core/gameState.js index 12d3af1..0fd249f 100644 --- a/html/js/core/gameState.js +++ b/html/js/core/gameState.js @@ -34,6 +34,8 @@ var GameState = { this._message = null; this._lighting = {}; this._torches = []; + this._depth = 1; + this._maxDepth = 1; if (this._scheduler) this._scheduler.clear(); if (!this._scheduler) this._scheduler = new ROT.Scheduler.Simple(); @@ -209,6 +211,18 @@ var GameState = { try { let needsRedraw = false; + // Multi-level descent: a new depth means a brand-new level. The accumulated map / + // explored / visible tiles belong to the level we just left, so clear them — otherwise + // the old level's walls ghost through the fresh one. + if (typeof data.depth === 'number' && data.depth !== this._depth) { + this._depth = data.depth; + if (typeof data.maxDepth === 'number') this._maxDepth = data.maxDepth; + this._map = {}; + this._visibleTiles = {}; + this._exploredTiles = {}; + needsRedraw = true; + } + // Update player state if (data.player) { this._player = data.player; diff --git a/src/game/difficultyConfig.js b/src/game/difficultyConfig.js index 67d7c87..2ae3e2f 100644 --- a/src/game/difficultyConfig.js +++ b/src/game/difficultyConfig.js @@ -147,39 +147,30 @@ function getDifficultyPreset(cryptoType = 'WOW', overridePreset = null) { return { ...DIFFICULTY_PRESETS.casino, presetName: 'casino' }; } -// Per-network SIZE tuning so cryptoType shapes difficulty (it used to be a dead parameter). -// sizeScale ∝ √(blockTime/ref) — PACING: a run's length as a consistent fraction of the block -// interval, applied to EVERY preset. Reference = XMR (2-min blocks) = 1.0. Clamped [0.7, 1.6] -// so single maps stay playable. -// -// DELIBERATELY NO monster-speed lever. The sim proved a supernaturally fast monster (a) feels like -// cheating and (b) doesn't even WORK on slow chains — WOW (5-min blocks) caps ~64% house-win even -// at a 2.2× monster on a 111×55 map, because a single dungeon clears in ~1 min << the block, so the -// timer (the fair, on-theme "race the block" edge) barely bites. The real edge lever for slow chains -// is MULTI-LEVEL DEPTH (cumulative run length across levels), which is the next build. Until then, -// slow chains (WOW/BTC) run honest-but-under-target on the timer; the monster stays at its fair -// preset speed. Operator env overrides (DUNGEON_*, MONSTER_*) still win. Kill: NETWORK_TUNING_DISABLED=true. +// Per-network tuning so cryptoType shapes difficulty (it used to be a dead parameter). The pacing +// lever is LEVELS (multi-level depth ∝ block time): a run descends `levels` normal-sized dungeons, +// so cumulative run length makes the block-timer bite on slow chains WITHOUT a giant single map or +// a cheating-fast monster (the sim proved both fail — size self-cancels, and a 2.2× monster still +// caps WOW ~64%). Each level is base-sized with a FAIR monster; the edge comes from racing the block +// across the whole descent. Levels ≈ blockTime / ~75s (one normal level), clamped [1, 10]. +// GRIN 1m → 1 XMR 2m → 2 LTC 2.5m → 2 WOW 5m → 4 BTC 10m → 8 +// NOTE: the exact per-network level counts are sim starting points (calibration re-run is pending +// the sim's multi-level support). Operator env overrides (DUNGEON_LEVELS, DUNGEON_*, MONSTER_*) win. +// Kill: NETWORK_TUNING_DISABLED=true. const NETWORK_TUNING = { - GRIN: { sizeScale: 0.71 }, // ~1 min blocks - XMR: { sizeScale: 1.00 }, // ~2 min (reference) - LTC: { sizeScale: 1.12 }, // ~2.5 min - WOW: { sizeScale: 1.60 }, // ~5 min (measured) — wants multi-level, not one giant map - BTC: { sizeScale: 1.60 } // ~10 min — wants multi-level + GRIN: { levels: 1 }, // ~1 min blocks + XMR: { levels: 2 }, // ~2 min + LTC: { levels: 2 }, // ~2.5 min + WOW: { levels: 4 }, // ~5 min (measured) + BTC: { levels: 8 } // ~10 min }; -// Fold the per-network SIZE scale into a resolved preset (pacing; monster left at the fair preset speed). +// Fold the per-network level count onto the resolved preset (size + monster stay at the preset). 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; - return { - ...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)) - } - }; + return { ...preset, levels: t.levels }; } /** @@ -212,8 +203,14 @@ function getDifficultyConfig(cryptoType = 'WOW', customOverrides = {}) { if (env.TREASURE_ROOM_POSITION) envOverrides.treasure.roomPositionRatio = parseFloat(env.TREASURE_ROOM_POSITION); if (env.TREASURE_EXIT_DISTANCE) envOverrides.treasure.distanceFromExitRatio = parseFloat(env.TREASURE_EXIT_DISTANCE); + // Level count: operator env DUNGEON_LEVELS wins, else custom, else the network tuning, else 1. + const levels = env.DUNGEON_LEVELS + ? Math.max(1, parseInt(env.DUNGEON_LEVELS, 10) || 1) + : (customOverrides.levels || preset.levels || 1); + return { ...preset, + levels, dungeon: { ...preset.dungeon, ...envOverrides.dungeon, ...customOverrides.dungeon }, monster: { ...preset.monster, ...envOverrides.monster, ...customOverrides.monster }, treasure: { ...preset.treasure, ...envOverrides.treasure, ...customOverrides.treasure } diff --git a/src/game/game.js b/src/game/game.js index bca4d8a..46f171d 100644 --- a/src/game/game.js +++ b/src/game/game.js @@ -5,7 +5,7 @@ const Monster = require('./monster.js'); const DungeonGenerator = require('./dungeon.js'); const LightingAndFov = require('./lightingAndFov.js'); const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('./difficultyConfig'); -const { createGameProof, getPreGameCommitment, getPostGameReveal, createSeededRNG, seedToInt, seededShuffle } = require('./provablyFair'); +const { createGameProof, getPreGameCommitment, getPostGameReveal, createSeededRNG, seedToInt, levelSeed, seededShuffle } = require('./provablyFair'); // Environment-based console logging control const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development'; @@ -102,60 +102,81 @@ class Game { // Get difficulty settings const difficultyConfig = getDifficultyConfig(process.env.CRYPTO_TYPE || 'WOW'); this.difficultyConfig = difficultyConfig; - + + // Multi-level descent: a run spans `maxDepth` levels (a pacing knob ∝ block time, from the + // per-network tuning). Reaching a non-final exit descends to a fresh level; only the final + // level's exit escapes. Defaults to 1 (single level) when no tuning is present. + this.depth = 1; + this.maxDepth = Math.max(1, parseInt(difficultyConfig.levels, 10) || 1); + if (CONSOLE_LOGGING) { console.log(`[Game.initializeGame] Using difficulty preset: ${difficultyConfig.presetName}`); console.log(`[Game.initializeGame] Target house win rate: ${difficultyConfig.targetHouseWinRate * 100}%`); + console.log(`[Game.initializeGame] Levels this run: ${this.maxDepth}`); } - + // Initialize game state this.gameState = 'waiting'; this.startBlock = null; this.player = new Player(); - this.monster = new Monster(0, 0, { - visionRange: difficultyConfig.monster.visionRange || 12 - }); this.monsterMoveAccumulator = 0; // For fractional monster moves this.fee = 0; - // Generate dungeon with the provided dimensions and config. - // Pass the per-game seeded RNG + numeric seed so the layout is deterministic - // and verifiable from the committed seed (provably fair). - this.dungeon = DungeonGenerator.generate(this.gameConfig.width, this.gameConfig.height, { - ...this.gameConfig, - rng: this.seededRNG, - seedInt: this.seedInt - }); - - // Place player at entrance - if (this.dungeon.entrance) { - this.player.moveTo(this.dungeon.entrance[0], this.dungeon.entrance[1]); - } - - // Place monster based on difficulty config (closer = harder) - if (this.dungeon.rooms && this.dungeon.rooms.length > 2) { - const monsterRoomIndex = getMonsterSpawnRoomIndex( - this.dungeon.rooms, - difficultyConfig.monster.startDistanceFromPlayer - ); - const monsterRoom = this.dungeon.rooms[monsterRoomIndex]; - const center = monsterRoom.getCenter(); - this.monster.moveTo(center[0], center[1]); - - if (CONSOLE_LOGGING) { - console.log(`[Game.initializeGame] Monster spawned in room ${monsterRoomIndex}/${this.dungeon.rooms.length - 1} (distance ratio: ${difficultyConfig.monster.startDistanceFromPlayer})`); - } - } + // Generate the first level (also creates the monster + FOV for it). + this._generateLevel(1); - // Initialize lighting and FOV - this.lightingAndFov = new LightingAndFov(this.dungeon.map, this.dungeon.torches); - this._fovInstance = LightingAndFov.initializeFOV(this.dungeon.map, this.gameConfig); - const fovRadius = this.gameConfig.fovRadius || 10; - this.visibleTiles = LightingAndFov.updateFOV(this._fovInstance, this.player, this.dungeon.map, fovRadius); - // Set game state to active this.gameState = 'active'; } + + /** + * Generate the dungeon for a given level and place the player + a fresh monster on it. The layout + * is deterministic from the committed seed via a per-level seed (level 1 = master seed, so + * single-level games are unchanged; deeper levels salt it), keeping the whole descent verifiable. + * Treasure lives only in the vault (the final level) — intermediate levels are a race to the stairs. + * @param {number} depth - 1-based level index + */ + _generateLevel(depth) { + const isFirst = depth === 1; + const seed = levelSeed(this.gameProof.seed, depth); + const dungeon = DungeonGenerator.generate(this.gameConfig.width, this.gameConfig.height, { + ...this.gameConfig, + rng: isFirst ? this.seededRNG : createSeededRNG(seed), + seedInt: isFirst ? this.seedInt : seedToInt(seed) + }); + + // Only the final level holds the treasure — descend all the way for the reward. + if (depth < this.maxDepth) { + dungeon.treasure = null; + } + this.dungeon = dungeon; + + // Place player at the entrance. + if (dungeon.entrance) { + this.player.moveTo(dungeon.entrance[0], dungeon.entrance[1]); + } + + // Fresh monster per level, placed by difficulty config (closer = harder). + this.monster = new Monster(0, 0, { visionRange: this.difficultyConfig.monster.visionRange || 12 }); + this.monsterMoveAccumulator = 0; + if (dungeon.rooms && dungeon.rooms.length > 2) { + const idx = getMonsterSpawnRoomIndex(dungeon.rooms, this.difficultyConfig.monster.startDistanceFromPlayer); + const center = dungeon.rooms[idx].getCenter(); + this.monster.moveTo(center[0], center[1]); + } + + // Reset lighting + FOV for the new level (a fresh descent is unexplored). + this.lightingAndFov = new LightingAndFov(dungeon.map, dungeon.torches); + this._fovInstance = LightingAndFov.initializeFOV(dungeon.map, this.gameConfig); + const fovRadius = this.gameConfig.fovRadius || 10; + this.visibleTiles = LightingAndFov.updateFOV(this._fovInstance, this.player, dungeon.map, fovRadius); + } + + /** Take the stairs down to the next level (regenerates the dungeon, keeps player identity/treasure). */ + _descend() { + this.depth += 1; + this._generateLevel(this.depth); + } updateFOV() { this.visibleTiles = LightingAndFov.updateFOV(this._fovInstance, this.player, this.dungeon.map, 10); @@ -205,6 +226,15 @@ class Game { // Check for game events like finding treasure or exit if (this.dungeon.exit && this.player.isAt(this.dungeon.exit[0], this.dungeon.exit[1])) { + // Not at the bottom yet → take the stairs down to a fresh level (NOT a win). The new level + // is generated here, so the returned state (and the next getState) already reflects it. + if (this.depth < this.maxDepth) { + this._descend(); + return { + status: 'moved', event: 'descend', depth: this.depth, maxDepth: this.maxDepth, + player: this.player.getState(), visibleTiles: this.visibleTiles, moves: this.moveCount + }; + } this.gameState = 'won'; return { status: 'moved', event: 'escaped', player: this.player.getState(), visibleTiles: this.visibleTiles, moves: this.moveCount }; } @@ -298,6 +328,8 @@ class Game { entrance: this.dungeon ? this.dungeon.entrance : null, exit: this.dungeon ? this.dungeon.exit : null, treasure: this.dungeon ? this.dungeon.treasure : null, + depth: this.depth, + maxDepth: this.maxDepth, moves: this.moveCount, startedAt: this.startedAt }; diff --git a/src/game/provablyFair.js b/src/game/provablyFair.js index 0fe8c95..792bcbf 100644 --- a/src/game/provablyFair.js +++ b/src/game/provablyFair.js @@ -111,6 +111,19 @@ function seedToInt(seed) { return Number.isFinite(n) && n > 0 ? n : 1; } +/** + * Derive a per-level seed for a multi-level run. Level 1 uses the master seed verbatim (so + * single-level games stay byte-identical to pre-multi-level behaviour); deeper levels salt the + * master seed with the depth. The whole descent is therefore reproducible from the one committed + * seed — feed (masterSeed, depth) to `createSeededRNG`/`seedToInt` to regenerate any level. + * @param {string} masterSeed - The game's committed seed + * @param {number} depth - 1-based level index + * @returns {string} The seed string for that level + */ +function levelSeed(masterSeed, depth) { + return depth > 1 ? String(masterSeed) + ':L' + depth : String(masterSeed); +} + /** * Generate a random integer in range [min, max] using seeded RNG * @param {function} rng - Seeded RNG function @@ -241,6 +254,7 @@ module.exports = { deriveSeed, createSeededRNG, seedToInt, + levelSeed, seededRandomInt, seededShuffle, createGameProof, diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index b3f3f7f..f0d56aa 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -308,6 +308,13 @@ class SocketHandlers { return; } + // Player took the stairs down to a deeper level — the fresh level was already generated in + // movePlayer and rides out in the normal game_update. Don't end the game, and don't advance + // the new level's monster on the arrival turn. + if (moveResult && moveResult.event === 'descend') { + return; + } + let monsterResult = null; // Move monster one step toward player each player action diff --git a/src/sim/simBots.js b/src/sim/simBots.js index 3b81cae..8a55c81 100644 --- a/src/sim/simBots.js +++ b/src/sim/simBots.js @@ -75,6 +75,7 @@ function explorerBot(opts = {}) { const known = new Set(); // "x,y" of every cell ever seen (floor or wall) const seenExit = { at: null }; const seenTreasure = { at: null }; + let lastDepth = 1; // reset fog-of-war on a multi-level descent (fresh level) function reveal(game) { const px = game.player.x, py = game.player.y; @@ -110,6 +111,10 @@ function explorerBot(opts = {}) { id: wantTreasure ? 'explorer-greedy' : 'explorer-escape', vision, move(game) { + // On a descent, the old level's knowledge is stale — start the new level unexplored. + if (game.depth && game.depth !== lastDepth) { + known.clear(); seenExit.at = null; seenTreasure.at = null; lastDepth = game.depth; + } reveal(game); const cols = game.width, rows = game.height; const hazard = monsterHazard(game); diff --git a/test/multiLevel_ultra.test.js b/test/multiLevel_ultra.test.js new file mode 100644 index 0000000..fba72db --- /dev/null +++ b/test/multiLevel_ultra.test.js @@ -0,0 +1,86 @@ +// Multi-level descent: a run spans `maxDepth` levels, each a fresh deterministic dungeon, treasure +// only in the vault (final level), and only the final exit escapes. Verifies the mechanic + that +// every level stays reproducible from the one committed seed (provably fair across the descent). +const Game = require('../src/game/game'); +const DungeonGenerator = require('../src/game/dungeon'); +const { levelSeed } = require('../src/game/provablyFair'); + +const SAVE = {}; +const KEYS = ['DUNGEON_LEVELS', 'CRYPTO_TYPE', 'DIFFICULTY_PRESET', 'NETWORK_TUNING_DISABLED', 'PAYMENTS_ENABLED', 'GAME_MODE']; +beforeEach(() => { KEYS.forEach(k => { SAVE[k] = process.env[k]; delete process.env[k]; }); process.env.DIFFICULTY_PRESET = 'normal'; }); +afterEach(() => { KEYS.forEach(k => { if (SAVE[k] === undefined) delete process.env[k]; else process.env[k] = SAVE[k]; }); }); + +const user = () => ({ id: 0, username: 'test', endGame() {} }); + +describe('multi-level descent', () => { + test('descends through levels; treasure only in the vault; stays active until the final exit', () => { + process.env.DUNGEON_LEVELS = '3'; + const game = new Game('sock', user(), {}); + expect(game.maxDepth).toBe(3); + expect(game.depth).toBe(1); + expect(game.gameState).toBe('active'); + expect(game.dungeon.treasure).toBeNull(); // level 1/3 — no treasure + + game._descend(); + expect(game.depth).toBe(2); + expect(game.gameState).toBe('active'); // descending is NOT winning + expect(game.dungeon.treasure).toBeNull(); // level 2/3 — still no treasure + expect(game.dungeon.entrance).toBeTruthy(); + expect(game.dungeon.exit).toBeTruthy(); + + game._descend(); + expect(game.depth).toBe(3); + expect(game.dungeon.treasure).not.toBeNull(); // the vault — treasure present + }); + + test('every level is reproducible from the committed seed + depth (provably fair)', () => { + process.env.DUNGEON_LEVELS = '3'; + process.env.CRYPTO_TYPE = 'WOW'; + const game = new Game('sock', user(), {}); + const seed = game.gameProof.seed; + + // Level 1 == regenerate from the master seed (unchanged single-level behaviour). + const regen1 = DungeonGenerator.regenerateFromSeed(levelSeed(seed, 1), 'WOW'); + expect(game.dungeon.map).toEqual(regen1.map); + + game._descend(); // level 2 + const regen2 = DungeonGenerator.regenerateFromSeed(levelSeed(seed, 2), 'WOW'); + expect(game.dungeon.map).toEqual(regen2.map); + + // Deeper levels are salted → a different layout from level 1. + expect(regen2.map).not.toEqual(regen1.map); + }); + + test('single-level default: an untuned network runs one level and its exit escapes', () => { + process.env.CRYPTO_TYPE = 'TESTNET'; // unknown → no level tuning → maxDepth 1 + const game = new Game('sock', user(), {}); + expect(game.maxDepth).toBe(1); + expect(game.dungeon.treasure).not.toBeNull(); // single level IS the vault + + // Walk onto the exit → escaped (won), not descend. + const [ex, ey] = game.dungeon.exit; + game.player.moveTo(ex - 1, ey); // stand next to the exit on a floor cell if possible + // Force the player adjacent by teleporting onto an exit-adjacent floor, then step in. If the + // left neighbour isn't floor, just teleport onto the exit-1 and assert via a direct exit check. + game.player.moveTo(ex, ey); // already on exit — re-derive win via a no-op-ish move + // A fresh move INTO the exit from a neighbour: place on a known floor neighbour of the exit. + const map = game.dungeon.map; + const nbrs = [[ex - 1, ey], [ex + 1, ey], [ex, ey - 1], [ex, ey + 1]]; + const floor = nbrs.find(([x, y]) => map[y] && (map[y][x] === "'1" || map[y][x] === "'2")); + if (floor) { + game.player.moveTo(floor[0], floor[1]); + const res = game.movePlayer(ex - floor[0], ey - floor[1]); + expect(res.event).toBe('escaped'); + expect(game.gameState).toBe('won'); + } else { + // Degenerate layout (exit fully walled) — the descent path is still covered by other tests. + expect(game.maxDepth).toBe(1); + } + }); + + test('DUNGEON_LEVELS env override sets the run length', () => { + process.env.DUNGEON_LEVELS = '5'; + const game = new Game('sock', user(), {}); + expect(game.maxDepth).toBe(5); + }); +}); diff --git a/test/networkTuning_ultra.test.js b/test/networkTuning_ultra.test.js index b47d6cf..303f9fd 100644 --- a/test/networkTuning_ultra.test.js +++ b/test/networkTuning_ultra.test.js @@ -1,5 +1,6 @@ -// 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. +// Guards the per-network difficulty tuning: cryptoType must set LEVELS (multi-level depth ∝ block +// time), size + monster must stay at the fair preset, block-time ordering must hold, and operator +// env overrides must still win. const { DIFFICULTY_PRESETS, NETWORK_TUNING, applyNetworkTuning, getDifficultyConfig } = require('../src/game/difficultyConfig'); @@ -9,57 +10,50 @@ 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']; +const KEYS = ['NETWORK_TUNING_DISABLED', 'DUNGEON_WIDTH', 'DUNGEON_LEVELS', '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('XMR is the reference (sizeScale 1.0) — casino dims unchanged; WOW (5-min) scales up', () => { - const xmr = applyNetworkTuning(casino(), 'XMR'); - expect(xmr.dungeon.width).toBe(DIFFICULTY_PRESETS.casino.dungeon.width); - expect(xmr.dungeon.height).toBe(DIFFICULTY_PRESETS.casino.dungeon.height); - // WOW is a slow chain now (5 min) → bigger map than XMR. - expect(applyNetworkTuning(casino(), 'WOW').dungeon.width).toBeGreaterThan(xmr.dungeon.width); +describe('per-network difficulty tuning (multi-level)', () => { + test('tuning sets LEVELS ∝ block time, leaving size + monster at the preset', () => { + const w = applyNetworkTuning(casino(), 'WOW'); + expect(w.levels).toBe(NETWORK_TUNING.WOW.levels); + // Size and monster are untouched — depth is the pacing lever, not size/monster-speed. + expect(w.dungeon.width).toBe(DIFFICULTY_PRESETS.casino.dungeon.width); + expect(w.monster.movesPerPlayerMove).toBe(DIFFICULTY_PRESETS.casino.monster.movesPerPlayerMove); }); - test('dungeon size scales with block time: GRIN < XMR < WOW', () => { - const g = applyNetworkTuning(casino(), 'GRIN').dungeon.width; - const x = applyNetworkTuning(casino(), 'XMR').dungeon.width; - const w = applyNetworkTuning(casino(), 'WOW').dungeon.width; - expect(g).toBeLessThan(x); - expect(x).toBeLessThan(w); + test('levels scale with block time: GRIN < XMR < WOW < BTC', () => { + const lv = (n) => applyNetworkTuning(casino(), n).levels; + expect(lv('GRIN')).toBeLessThan(lv('XMR')); + expect(lv('XMR')).toBeLessThanOrEqual(lv('LTC')); + expect(lv('LTC')).toBeLessThan(lv('WOW')); + expect(lv('WOW')).toBeLessThan(lv('BTC')); + expect(lv('GRIN')).toBe(1); // fast chain = single level }); - test('tuning scales SIZE only — the monster stays at its fair preset speed', () => { - // No cheating-fast monster: every preset keeps its own movesPerPlayerMove. - expect(applyNetworkTuning(casino(), 'BTC').monster.movesPerPlayerMove) - .toBe(DIFFICULTY_PRESETS.casino.monster.movesPerPlayerMove); - expect(applyNetworkTuning(normal(), 'BTC').monster.movesPerPlayerMove) - .toBe(DIFFICULTY_PRESETS.normal.monster.movesPerPlayerMove); - // ...but both get the pacing size scale. - expect(applyNetworkTuning(normal(), 'BTC').dungeon.width).toBeGreaterThan(DIFFICULTY_PRESETS.normal.dungeon.width); - expect(NETWORK_TUNING.BTC.monsterSpeed).toBeUndefined(); + test('applies to every preset (levels is preset-agnostic pacing)', () => { + expect(applyNetworkTuning(normal(), 'WOW').levels).toBe(NETWORK_TUNING.WOW.levels); }); - 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', () => { + test('getDifficultyConfig surfaces levels; defaults to 1 for an unknown/untuned network', () => { 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 + expect(getDifficultyConfig('BTC').levels).toBe(NETWORK_TUNING.BTC.levels); + expect(getDifficultyConfig('DOGE').levels).toBe(1); }); - test('NETWORK_TUNING_DISABLED=true reverts to the raw preset', () => { + test('operator env overrides win: DUNGEON_LEVELS + DUNGEON_WIDTH beat the tuning', () => { + process.env.DIFFICULTY_PRESET = 'casino'; + process.env.DUNGEON_LEVELS = '3'; + process.env.DUNGEON_WIDTH = '99'; + const cfg = getDifficultyConfig('BTC'); + expect(cfg.levels).toBe(3); // env beats BTC's 8 + expect(cfg.dungeon.width).toBe(99); + }); + + test('NETWORK_TUNING_DISABLED=true → no levels tuning (single level)', () => { 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); + expect(getDifficultyConfig('BTC').levels).toBe(1); }); });