Files
wownerogue/docs/BALANCE_SIM.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

6.3 KiB

Balance simulation and per-network calibration

Wownerogue is a block-timed roguelike: a single-player run is bounded by the blockchain, and the house edge comes from whether the player clears the dungeon before the next block lands. The deadline is a random variable and every supported chain has a different block time, so difficulty is measured rather than estimated. This document covers the simulator that measures it and the per-network tuning it feeds.

Timing model

A run starts on block N and ends as a loss if the player has not escaped by the time block N+1 arrives (src/network/socketHandlers.js, checkGamesTimeout). Movement is real time within that window, with a 100 ms per-move cooldown (src/game/movementManager.js). A grace period, GAME_START_GRACE_MS (default 2000 ms, 0 disables), suppresses only the degenerate case where a block lands in the same instant the game starts; random block timing is otherwise preserved intact.

Block arrival is a Poisson process, so the run window is exponentially distributed with mean equal to the network's block time. Entering just after a block yields a near-full interval; entering just before yields seconds. That variance is the core mechanic.

For a run that escapes at wall-clock time T:

P(survive)     = P(block lands after T) = exp(-T / meanBlockTime)
house_win(run) = caught or stuck ? 1 : (1 - exp(-T / meanBlockTime))

The random deadline is integrated out analytically, so no Monte Carlo over deadlines is required.

Mean block times come from src/chain/chainProfile.js (meanBlockTimeMs):

Chain Mean block time
GRIN 1 min
XMR 2 min
LTC 2.5 min
WOW 5 min
BTC 10 min

Unknown or unset chain ids resolve to a fallback profile of 12 decimals and a 120000 ms block time, so a mis-set CRYPTO_TYPE degrades to XMR-shaped timing instead of crashing.

The simulator (src/sim/)

The harness drives the real engine (src/game/game.js: same dungeon generator, monster AI and movement as production) with headless bots until each run reaches a terminal outcome, then reports the measured distribution. It never reimplements game rules; it is a thin layer over Game.

File Role
simulate.js Harness and CLI. Per preset: escape, treasure, caught and stuck rates; completion-move and completion-time percentiles; measured house-win per network via the identity above.
simBots.js Bot policies. omniscient-* know the full map and take BFS-optimal paths; explorer-* play under fog of war and reset their knowledge on descent. Neither policy evades the monster.
pathfind.js Dependency-free 4-connected BFS distance field and downhill step, so navigation stays auditable and separate from game rules.
calibrate.js Per-network solver: scales dungeon size by √(blockTime / 120000) for pacing, then binary-searches monster speed to a target house-win.

Run:

node src/sim/simulate.js --runs 200 --bot explorer-greedy --presets normal,casino
node src/sim/calibrate.js --target 0.70 --preset casino

simulate.js accepts --runs, --bot, --presets, --nets, --cadence, --movecap and --json. Bots are omniscient-escape, omniscient-greedy, explorer-escape and explorer-greedy (default).

--cadence (ms per move, default 320) is the moves-to-seconds assumption the block-time math rests on; sweep it when calibrating against real player pacing.

Two properties of the harness matter when reading its output:

  • Because neither bot evades the monster, both over-count catches, so reported house-win is an upper band rather than a point estimate. A skilled human wins more often.
  • One simulated dungeon configuration is scored against every network in --nets. The dungeon itself is built from DIFFICULTY_PRESET and the process CRYPTO_TYPE (default WOW), so the per-network columns differ only in block time, not in level count. Set CRYPTO_TYPE explicitly to simulate a specific chain's level count.

test/sim_ultra.test.js guards the harness structurally: it asserts that bots reach terminal outcomes and that rates are well formed and partition the runs. It asserts nothing about balance values, which are expected to move.

Findings

  1. Presets declare a targetHouseWinRate but measured rates land below it, and the edge varies by roughly 15 points across chains for an identical dungeon purely from block time.
  2. Dungeon size is not a clean edge lever. A larger map lengthens the run (timer edge up) but gives the player more room to dodge (monster edge down), so house-win self-cancels near 60% until the map becomes an unplayable slog. Size is useful as a pacing knob, not an edge knob.
  3. A fast monster is both unpleasant and ineffective on slow chains. On WOW's 5-minute blocks a single dungeon clears in about a minute, so even a 2.2x monster caps house-win near 64% because the timer barely bites.

The lever that follows from this is run length: descend N preset-sized levels, each with a fair monster, so cumulative run time supplies the edge and levels scale with block time. monsterSpeed is deliberately not used as a per-network lever. See docs/MULTI_LEVEL.md for the mechanic.

Per-network tuning (NETWORK_TUNING)

src/game/difficultyConfig.js folds per-network tuning onto the resolved preset. Precedence, low to high: preset, network tuning, environment variables, explicit overrides. Operator settings such as DUNGEON_*, MONSTER_* and DUNGEON_LEVELS therefore win over the tuning, and NETWORK_TUNING_DISABLED=true removes it entirely.

The tuning sets only levels. Dungeon size and monster parameters stay at the preset values.

Chain Levels
GRIN 1
XMR 1
LTC 2
WOW 2
BTC 3

Level counts are conservative relative to the raw pacing formula (block time divided by roughly 75 seconds per level), because a deep descent inside one block deadline is rarely completable and makes reach-the-exit-to-win unintuitive.

Game reads the resolved count as maxDepth. Reaching a non-final exit descends to a fresh level and emits a descend event; only the final level's exit ends the run as a win, and treasure is placed only on the final level. The client handles game_event (including descend) in html/js/network/socketHandlers.js and surfaces depth progress to the player.