mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-29 02:58:33 +01:00
feat(game): multi-level dungeons — descend N levels, timer-driven edge
The fair, on-theme edge lever for slow chains (from the calibration work): a run now descends `maxDepth` levels instead of one map, so cumulative run length makes the block-timer bite WITHOUT a giant single dungeon or a cheating-fast monster. Engine (game.js): - depth/maxDepth; _generateLevel(depth) builds each level (fresh dungeon + monster + FOV), _descend() steps down. Reaching a non-final exit descends (event "descend", stays active); only the final exit escapes (event "escaped"). - Treasure lives only in the vault (final level) — intermediate levels race to the stairs. Single-level (maxDepth=1) is byte-identical to before. - Provably fair per level: levelSeed(masterSeed, depth) (provablyFair.js) — level 1 = master seed, deeper levels salted; the whole descent regenerates from the one committed seed. Verified: game levels == regenerateFromSeed(levelSeed(seed,d)). Pacing (difficultyConfig.js): NETWORK_TUNING now sets LEVELS ∝ block time (GRIN 1, XMR 2, LTC 2, WOW 4, BTC 8) — replacing the size-scale hack; size + monster stay at the fair preset. DUNGEON_LEVELS operator override; NETWORK_TUNING_DISABLED → 1 level. Wiring: socketHandlers afterPlayerMove gets a "descend" branch (no game-over, no monster move on arrival). Client gameState.js clears the accumulated map/explored/ visible tiles on a depth change so the old level does not ghost through the new one. Sim: explorer bot resets fog-of-war on descent so multi-level sims stay honest (full re-calibration of the level counts is the pending follow-up). Tests: multiLevel_ultra (descent, vault-only treasure, per-level provably-fair, single-level default, DUNGEON_LEVELS) + networkTuning rewritten for the levels model. Suite 61 suites / 432 green; end-to-end 4-level WOW run descends 3x then escapes.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user