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);
});
});