Files
wownerogue/docs/MULTI_LEVEL.md
jwinterm ed24d43b56 chore: prepare the repository for public release
Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.

Private material moved out of the public tree (docs/private/, git-ignored):
  - the wallet keyfile recovery runbook and its helper scripts, which together
    published the mainnet wallet file path, the service account, backup snapshot
    filenames, and the wallet RPC listener state. The helper duplicated the same
    constants, so relocating the document alone would not have removed them.
  - the stagenet parallel-candidate runbook and its fixtures.
  - the dated production-state record, the internal remediation plan, and the
    TODO list. None are reference documentation for an outside reader.

Redacted or genericised:
  - operator home-directory paths in shipped scripts and units. The asset build
    script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
  - private LAN addresses in tests, env examples and deploy docs, replaced with
    RFC 5737 documentation addresses.
  - the default alert sender and the notification hub example.
  - the Monero wallet unit now matches the Wownero one: a dedicated service
    account and a config file, instead of the operator's login account with the
    wallet password in argv where any local user can read it from /proc.

Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.

Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.

Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.

Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.

127 suites / 1015 tests pass.
2026-07-27 06:40:38 -04:00

5.1 KiB

Multi-level dungeons

A run descends through maxDepth levels instead of playing a single map. Depth is the pacing lever that lets the block timer supply the house edge on slow chains without an oversized dungeon or a monster that moves faster than the player. See BALANCE_SIM.md for the balance model behind that choice.

Mechanic

  • Each level is a normal preset-sized dungeon with its own entrance, exit, and a freshly placed monster.
  • Reaching a non-final exit takes the stairs down: a new level is generated, the player is placed at its entrance, a new monster spawns, and fog of war resets. Descending is not a win.
  • Reaching the final level's exit escapes the dungeon and wins the run.
  • Treasure exists only on the final level. Intermediate levels are a pure race to the stairs, so the treasure payout multiplier requires descending all the way and getting back out.
  • maxDepth = 1 is the single-level case: levelSeed returns the master seed unchanged at depth 1, so a one-level run's layout is exactly what the master seed alone produces.
  • The monster does not take its turn on the arrival move of a descent. afterPlayerMove returns early on a descend result, so the player is never caught by a monster they have not yet seen.

Provable fairness across the descent

Every level in a run regenerates from the one committed seed. levelSeed(masterSeed, depth) in provablyFair.js returns the master seed verbatim for level 1 and salts it as <seed>:L<depth> for deeper levels. Any level can be reproduced with:

DungeonGenerator.regenerateFromSeed(levelSeed(seed, depth), cryptoType, generationOptions);

The Game constructor commits a layout fingerprint for every level of the run up front, before play, in gameProof.layoutFingerprints (one entry per depth, each carrying depth, fingerprintVersion, generatorVersion, and the SHA-256 fingerprint). gameProof.layoutFingerprint remains as a level-1 alias for older verification clients. Because the manifest is complete at creation time, a run that ends early still exposes a full, immutable record of the advertised descent.

Each level is re-fingerprinted as it is actually generated during play, and a mismatch against the committed manifest throws. Generator drift inside a live process becomes a hard failure rather than an unverifiable served layout.

src/game/fairnessVerifier.js replays the whole manifest: it rejects a manifest whose depths are incomplete or out of order, regenerates each level from its per-depth seed, nulls the treasure on every non-final level to match play, and reports per-level fingerprint matches alongside entrance, exit, treasure, and dimensions.

Configuration

maxDepth comes from difficultyConfig.levels, set per chain by NETWORK_TUNING in src/game/difficultyConfig.js. Depth scales with block time so that a slower chain gives a longer run before the block lands:

Chain Approx. block time Levels
GRIN ~1 min 1
XMR ~2 min 1
LTC ~2.5 min 2
WOW ~5 min 2
BTC ~10 min 3

Resolution order for the level count, lowest precedence first: preset (no levels key, so the effective floor is 1) → NETWORK_TUNING for the chain → customOverrides.levels → the DUNGEON_LEVELS environment variable, which always wins and is clamped to a minimum of 1.

NETWORK_TUNING_DISABLED=true skips per-network tuning entirely, which leaves the run at a single level unless DUNGEON_LEVELS or an explicit override supplies a count. An unknown cryptoType is also left untuned and therefore single-level.

Dungeon size and monster behaviour are not varied by depth: applyNetworkTuning folds only the level count onto the resolved difficulty preset, and every level uses the same preset dimensions and the same fair monster settings.

Where it lives

Concern Location
Level generation and descent src/game/game.js: _generateLevel(depth), _descend(), the exit branch in movePlayer returning event: 'descend', and depth/maxDepth in getState
All-level fingerprint manifest src/game/game.js: _buildLayoutFingerprintManifest(), getProofContext()
Per-level seed src/game/provablyFair.js: levelSeed()
Level count src/game/difficultyConfig.js: NETWORK_TUNING, applyNetworkTuning(), DUNGEON_LEVELS
Verification src/game/fairnessVerifier.js
Server wiring src/network/socketHandlers.js: the 'descend' branch in afterPlayerMove (no game over, no monster move on arrival)
Event emission src/game/movementManager.js: emits game_event with event, depth, maxDepth after the game_update
Client state html/js/core/gameState.js: clears accumulated map, explored, and visible tiles on a depth change so the previous level does not ghost through the new one
Client feedback html/js/network/socketHandlers.js: the game_event handler renders the descend message and on-screen banner with Level N / M
Sim src/sim/simBots.js: the explorer bot clears its known-tile set and remembered objectives on a depth change
Tests test/multiLevel_ultra.test.js, test/networkTuning_ultra.test.js