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.
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 fromDIFFICULTY_PRESETand the processCRYPTO_TYPE(defaultWOW), so the per-network columns differ only in block time, not in level count. SetCRYPTO_TYPEexplicitly 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
- Presets declare a
targetHouseWinRatebut measured rates land below it, and the edge varies by roughly 15 points across chains for an identical dungeon purely from block time. - 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.
- 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.