docs: pass for the 2026-07 build — balance sim, multi-level, render packs

- README: rewrite Difficulty section (per-network LEVELS ∝ block time, WOW=5min,
  multi-level descent, corrected casino size); document game_event + depth in the
  Socket.IO table.
- NEW docs/BALANCE_SIM.md: the block-timed edge math (exponential deadline), the
  headless sim over the real engine, what it proved (size self-cancels, fast monster
  fails on slow chains), and the per-network NETWORK_TUNING model.
- NEW docs/MULTI_LEVEL.md: descent mechanic, vault-only treasure, per-level
  provably-fair seeding, config, file map, open polish.
- NEW docs/RENDER_PACKS.md: modes-vs-packs, the RK.fx layer, iso/tiled/zoom/cat.
- docs/ARCHITECTURE.md: mark the block-time-difficulty gotcha RESOLVED; add render
  kit + balance/pacing sections and cross-links.
- docs/TODO.md: 2026-07 done/pending section.
This commit is contained in:
jwinterm
2026-07-11 22:29:57 -04:00
parent c5147de625
commit 643bec4721
6 changed files with 249 additions and 12 deletions
+37 -9
View File
@@ -51,16 +51,43 @@ crypto block as long as at least two players are queued. Supported economies:
See `docs/MATCH_MODE.md` for full architecture, configuration, and API details.
## Difficulty Presets
## Difficulty & Pacing
| Preset | Dungeon Size | Monster Aggression | House Win Rate |
|--------|--------------|--------------------|----|
| `easy` | 30×15 | Low | ~30% |
| `normal` | 45×22 | Medium | ~55% |
| `hard` | 55×28 | High | ~65% |
| `casino` | 60×30 | Very High | ~70% |
Difficulty has two levers, both keyed to the crypto network so `cryptoType` actually shapes the
game (it used to be a dead parameter).
Set via `DIFFICULTY_PRESET` in `.env`. Defaults to `casino` for paid modes.
**Presets** (`DIFFICULTY_PRESET`) — the per-level shape:
| Preset | Dungeon Size | Monster | Target House Win |
|--------|--------------|---------|------------------|
| `easy` | 30×15 | low | ~30% |
| `normal` | 45×22 | medium | ~55% |
| `hard` | 55×28 | high | ~65% |
| `casino` | 70×35 | very high | ~70% |
Defaults to `normal` for free play, `casino` for paid. Every knob is `env`-overridable
(`DUNGEON_WIDTH`, `MONSTER_SPEED`, …).
**Multi-level depth (`levels`) — the pacing lever, ∝ block time.** A run *descends* N levels (each a
preset-sized dungeon with a fair monster). Reaching a non-final exit takes the stairs down; only the
final exit escapes; the treasure sits in the vault (final level). Because total run length scales
with the chain's block time, the "race the block" **timer** provides the house edge — no giant map,
no cheating-fast monster.
| Chain | Block time | Levels |
|-------|-----------|--------|
| GRIN | ~1 min | 1 |
| XMR | ~2 min | 2 |
| LTC | ~2.5 min | 2 |
| WOW | ~5 min (measured) | 4 |
| BTC | ~10 min | 8 |
Levels live in `NETWORK_TUNING` (`src/game/difficultyConfig.js`); override with `DUNGEON_LEVELS`,
disable with `NETWORK_TUNING_DISABLED=true`. The counts are sim-derived starting points — see
[`docs/BALANCE_SIM.md`](docs/BALANCE_SIM.md) for how the balance is measured and calibrated.
> **Note:** Wownero's block time is **~5 min** (measured on the live daemon), not 2 min as older
> docs/comments claimed.
---
@@ -149,7 +176,8 @@ curl -X POST http://localhost:3000/api/admin/credits/adjust \
| Event | Description |
|-------|-------------|
| `game_start` | Game initialized with dungeon state |
| `game_update` | State after movement |
| `game_update` | State after movement (incl. `depth`/`maxDepth` for multi-level descent) |
| `game_event` | Discrete event: `treasure_found`, `descend` (took the stairs down), `escaped` |
| `game_over` | Game ended |
| `payment_created` | Payment address ready |
| `credits_update` | Credit balance changed |
+17 -3
View File
@@ -44,9 +44,23 @@ XMR, WOW, GRIN: `decimalsFor`, `meanBlockTimeMsFor`, `familyFor` (monero / utxo
`uriSchemeFor`, `atomicDivisor` (BigInt). Unknown chains fall back to a WOW-shaped default. Money math
stays exact via `src/money/atomic.js` (BigInt, decimals-parameterized) — never floats.
> **Gotcha (open):** `difficultyConfig` threads `cryptoType` everywhere but doesn't yet key difficulty
> off mean block time — it keys off payment mode. Wiring block-time → dungeon size is a pending
> game-balance change (see RULESETS.md), and it bites when Grin (~1 min) / BTC (~10 min) ship.
> **Resolved (2026-07):** `cryptoType` now keys difficulty off block time via `NETWORK_TUNING` in
> `difficultyConfig.js` — the pacing lever is **multi-level depth** (levels ∝ block time), measured
> and calibrated with a headless balance simulator. Also corrected: **WOW is ~5 min/block**, not 2
> (measured on the live daemon). See [BALANCE_SIM.md](BALANCE_SIM.md) and [MULTI_LEVEL.md](MULTI_LEVEL.md).
## Client render kit
`html/js/render/` (`RK`) draws the shared `Scene` in multiple techniques (Tiled / ASCII / Iso / 3D)
and interchangeable art **packs**, with a shared pure-canvas FX layer (torch/hearth fire, hazard
tiles), zoom, and an operator-gated cosmetic catalog. See [RENDER_PACKS.md](RENDER_PACKS.md).
## Game balance & pacing
Difficulty is **measured, not guessed**: single-player runs are bounded by a random (exponential)
block deadline, so the house edge is a simulation problem. `src/sim/` drives the real engine with
headless bots; `NETWORK_TUNING` + multi-level depth are the calibrated levers. See
[BALANCE_SIM.md](BALANCE_SIM.md) and [MULTI_LEVEL.md](MULTI_LEVEL.md).
## Regenerating the API reference (autodocs)
+82
View File
@@ -0,0 +1,82 @@
# Balance simulation & per-network calibration
Wownerogue is a **block-timed** roguelike: a run is bounded by the blockchain, and the house edge
comes from whether you can clear the dungeon before the block lands. That makes the balance
un-eyeball-able — the deadline is a *random* variable and every chain has a different block time —
so difficulty is **measured**, not guessed. This doc covers the simulator that measures it and the
per-network tuning it produces.
## The timing model (why this is subtle)
- **Single-player runs are bounded by one block.** You enter on block *N* and die if you haven't
escaped before *N+1* (`queueManager.js`, `socketHandlers.js checkGamesTimeout`). Movement is
real-time within that window (~100 ms/move floor).
- **Block arrival is a Poisson process**, so the run window is **exponentially distributed** with
mean = the network's block time. Enter just after a block → a near-full interval; just before →
seconds. High variance is the core mechanic, intended.
- Therefore, for a run that escapes at wall-clock time `T`:
```
P(survive) = P(block lands after T) = exp(-T / meanBlockTime)
house_win(run) = caught|stuck ? 1 : (1 - exp(-T / meanBlockTime))
```
The random deadline is integrated out analytically — no Monte-Carlo over deadlines needed.
Block times (`src/chain/chainProfile.js`, `meanBlockTimeMs`): GRIN ~1 min · XMR ~2 · LTC ~2.5 ·
**WOW ~5 (measured on the live daemon)** · BTC ~10.
## The simulator (`src/sim/`)
Drives the **real engine** (`src/game/game.js` — same dungeon generator, monster AI, movement as
production) with headless bots to terminal outcomes, then reports the measured distribution. It
never reimplements game rules — it's a thin layer over `Game`.
| File | Role |
|------|------|
| `simulate.js` | Harness + CLI. Per preset: escape/treasure/caught/stuck rates, completion-move/-time percentiles, and **measured house-win per network** via the identity above. |
| `simBots.js` | Bot policies. `omniscient-*` (knows the full map, beelines) and `explorer-*` (fog-of-war, realistic). Neither *evades* the monster, so both **over-count catches** → true house-win is likely below the reported band. Read them as an upper band, not a point estimate. |
| `pathfind.js` | Dependency-free BFS (keeps the sim auditable). |
| `calibrate.js`| Solves tuning per network to a target house-win. |
Run:
```bash
node src/sim/simulate.js --runs 200 --bot explorer-greedy --presets normal,casino
node src/sim/calibrate.js --target 0.70 --preset casino
```
`--cadence` (ms/move, default 320) is the moves→seconds assumption the block-time math hinges on —
sweep it when calibrating for real players. `test/sim_ultra.test.js` guards the harness structurally
(it is **not** a balance assertion — those numbers are meant to move).
## What the sim proved (and the design it forced)
1. **Presets undershot their declared targets**, and the edge swung ~15 pts across chains from
*identical* dungeons — because `cryptoType` never sized anything.
2. **Dungeon size is not a clean edge lever.** A bigger map lengthens the run (timer edge ↑) but
gives the player more room to dodge (monster edge ↓); house-win self-cancels around ~60% until
the map becomes an unplayable slog.
3. **A fast monster is both distasteful and ineffective on slow chains** — WOW (5-min blocks) caps
~64% house-win even at a 2.2× monster, because a single dungeon clears in ~1 min ≪ the block, so
the timer barely bites.
**Conclusion → multi-level depth.** The fair, on-theme lever is *run length*: descend N levels
(each preset-sized, fair monster) so the cumulative timer provides the edge. Levels ∝ block time.
See `docs/MULTI_LEVEL.md` (mechanic) and `NETWORK_TUNING` in `src/game/difficultyConfig.js`.
## Per-network tuning (`NETWORK_TUNING`)
`difficultyConfig.js` folds the tuning onto the resolved preset. Precedence:
**preset → network tuning → env → explicit overrides** (so operators keep `DUNGEON_*`/`MONSTER_*`;
`NETWORK_TUNING_DISABLED=true` is the kill switch). Currently it sets **`levels`** per network
(the pacing lever); size and monster stay at the fair preset.
```
GRIN 1 · XMR 2 · LTC 2 · WOW 4 · BTC 8
```
**Pending:** the level counts are sim starting points. The simulator's multi-level support (bots
reset fog-of-war on descent) is in; a full re-calibration run to lock the per-network level counts
against a target house-win is the open follow-up. The `monsterSpeed` lever was intentionally
**dropped** (see finding 3).
+48
View File
@@ -0,0 +1,48 @@
# Multi-level dungeons
A run descends through `maxDepth` levels instead of a single map. This is the pacing lever that
makes the block-timer provide the house edge on slow chains without a giant dungeon or a cheating
monster (see [`BALANCE_SIM.md`](BALANCE_SIM.md) for why).
## Mechanic
- Each level is a normal preset-sized dungeon with its own entrance, exit, and a fair monster.
- Reaching a **non-final** exit takes the **stairs down** → a fresh level is generated, the player
is placed at its entrance, a new monster spawns, and fog-of-war resets. This is **not** a win.
- Reaching the **final** level's exit **escapes** (win).
- **Treasure lives only in the vault** (the final level). Intermediate levels are a race to the
stairs; the 2×/3× treasure payout requires descending all the way and getting back out.
- `maxDepth = 1` (single level) is byte-identical to the pre-multi-level game.
## Provably fair across the descent
The whole run regenerates from the one committed seed. Per-level seed
(`levelSeed(masterSeed, depth)` in `provablyFair.js`): **level 1 = the master seed verbatim**
(so single-level games are unchanged), deeper levels salt it (`<seed>:L<depth>`). Verify any level
with `DungeonGenerator.regenerateFromSeed(levelSeed(seed, depth), cryptoType)`.
## Configuration
`maxDepth` comes from `difficultyConfig.levels`, set by `NETWORK_TUNING` per chain
(GRIN 1 · XMR 2 · LTC 2 · WOW 4 · BTC 8, ∝ block time). Operator overrides: `DUNGEON_LEVELS=<n>`;
`NETWORK_TUNING_DISABLED=true` forces a single level.
## Where it lives
| Concern | Location |
|---|---|
| Level generation + descent | `src/game/game.js` — `_generateLevel(depth)`, `_descend()`, the exit branch in `movePlayer` (returns `event: 'descend'`), `depth`/`maxDepth` in `getState` |
| Per-level seed | `src/game/provablyFair.js` — `levelSeed()` |
| Level count (pacing) | `src/game/difficultyConfig.js` — `NETWORK_TUNING`, `DUNGEON_LEVELS` |
| Server wiring | `src/network/socketHandlers.js` — `afterPlayerMove` `'descend'` branch (no game-over, no monster move on arrival) |
| Client | `html/js/core/gameState.js` — clears accumulated map/explored/visible tiles on a `depth` change so the old level doesn't ghost through the new one |
| Sim | `src/sim/simBots.js` — explorer bot resets fog-of-war on descent |
| Tests | `test/multiLevel_ultra.test.js` |
## Open polish
- Client **"Level N / M" indicator** + a "descended" toast (the server sends `depth`/`maxDepth` and
a `game_event: descend`; there is no client `game_event` handler yet).
- Per-network **level counts are sim starting points** — pending a full multi-level calibration run.
- **Escalating hazards per level** (level 1 tame → deep levels lava/poison) — the hazard-tile FX is
already wired (`RENDER_PACKS.md`); it needs the generator to place hazards + gameplay stakes.
+48
View File
@@ -0,0 +1,48 @@
# Render kit: modes, packs & FX
The client render kit (`html/js/render/`, namespace `RK`) draws the **same `Scene`** in multiple
techniques and art styles, so the tavern and dungeon are rich, interchangeable environments and new
art can be added as products.
## Modes vs. packs
- **Render modes = techniques** (how a scene is drawn): `Tiled`, `ASCII`, `Iso`, `3D`
(`renderModes.js`). Each declares a `projection` (`topdown` / `iso` / `3d`).
- **Packs = sets/styles within a projection** (chosen via the pack picker): e.g. topdown *Roguelike
Interior* ↔ *Roguelike Dungeon*, iso *Dungeon* ↔ *Medieval*. Registered in `packRegistry.js` /
`assetPacks.js`. "Fancy" is **not** a mode — it's a pack (tiles + FX).
- A premium mode unlocks once the user has **any** unlocked pack for its projection.
- The scene model is **kind-based** (`floor`/`wall`/`table`/`torch`/`monster`/…), so one pack renders
both the tavern (`sceneFromTavern`) and the dungeon (`sceneFromGameState`) — that's what makes
packs interchangeable across both.
Entitlement: packs are gated by the operator-owned cosmetic catalog (`cosmetic_catalog` table,
`src/multiplayer/entitlements.js`) — free / grant / lifetime-spend / subscription-tier. `?unlock=1`
sets a sticky local QA bypass. See `MONETIZATION.md`.
## The FX layer (`fxLayer.js`, `RK.fx`)
Pure-canvas animated FX (CSP-safe, no WebGL), shared by the tiled **and** iso renderers so a scene
lights up the same way in either projection:
- `RK.fx.fire` — dancing two-layer flame + warm glow (torches, hearths).
- `RK.fx.hazard` — pulsing overlay for hazard tiles (`lava` / `poison` / `spikes`) with a
footprint-clip callback so it fits square cells and iso diamonds alike.
- `RK.fx.flicker` / `pulse` / `glow` — the primitives.
A tile carries `fx: 'fire'` or `hazard: '<kind>'` in the scene legend (`sceneModel.js`); the renderer
draws only the tile's floor **base** in the static pass and lets `RK.fx` paint the animated layer in
the live loop. The tavern has wall braziers + a hearth; the dungeon hazard kinds are wired and render
the moment the generator emits their chars (`L`/`P`/`^`) — pending the generator + gameplay stakes.
## Other pieces
| File | Role |
|------|------|
| `isoRenderer.js` | Canvas isometric projection. Orientation-aware walls (`_S` for x-runs, `_W` for y-runs, corner tile), floor variety, contact shadows + vignette juice. Kenney iso dungeon set (`assets/kenney/iso-dungeon/`). |
| `tileRenderer.js`| Top-down atlas renderer. `over` compositing (object tiles drawn on a base tile), torch-lit vignette, ember/flicker loop, animated fire/hazard emitters. |
| `zoomControl.js` | `RK.attachZoom(host)` — wheel/pinch zoom (0.4–4.0), dblclick reset, pixelated scaling. |
| `catSprites.js` | Animated tavern cat (Pet Cats Pack idle strips). |
| `packRegistry.js`| Multi-pack-per-projection registry: active-pack selection persisted per projection, entitlement-gated, graceful fallback. Node-testable. |
Tests: `test/renderPackResolver.test.js`, `test/packRegistry_ultra.test.js`.
+17
View File
@@ -16,6 +16,23 @@ All milestones implemented. See `docs/MATCH_MODE.md` for architecture and config
# Wownerogue Development Todo
## Rendering, Balance & Multi-level (2026-07)
Done:
- [x] **Render packs**: modes (technique) vs packs (style) split; "Fancy" retired to a topdown pack; multi-pack registry; Kenney iso dungeon set + orientation-aware walls; per-projection pack picker. See `RENDER_PACKS.md`.
- [x] **FX layer (`RK.fx`)**: shared canvas fire/hazard/glow; tavern braziers + hearth; dungeon hazard tiles wired. Zoom (`zoomControl.js`); animated cat.
- [x] **Balance sim** (`src/sim/`): headless harness over the real engine; measured house-win per network. See `BALANCE_SIM.md`.
- [x] **Per-network tuning**: `cryptoType` now shapes difficulty via `NETWORK_TUNING` (was a dead param).
- [x] **WOW block-time fix**: 2 min → **5 min** (measured on the live daemon); `chainProfile.js`.
- [x] **Multi-level dungeons**: descend N levels ∝ block time; provably-fair per level; treasure in the vault. See `MULTI_LEVEL.md`.
Pending:
- [ ] **Multi-level calibration run**: lock per-network level counts against a target house-win (sim multi-level support is in; counts are currently starting points).
- [ ] **Block-bounded pot race** (flagship): consume `Ruleset.timing.blockDeadline`; add pot+rake economy (house = zero-risk rake-taker). Decisions pending: empty-pot rule, prize split.
- [ ] **PvP wiring**: connect the already-built `last-alive`/`score-attack` rulesets to a real queue.
- [ ] **Hazard gameplay**: generator places lava/poison/spikes (FX already renders them) + damage stakes; escalate per level.
- [ ] **Client polish**: "Level N/M" indicator + "descended" toast (server sends `depth`/`maxDepth` + `game_event: descend`).
## High Priority
- [x] **Verify Server Startup**: Server starts cleanly with `npm run dev` ✅
- [x] **Test Payment Flow**: Fixed critical bug where payment confirmation wasn't updating DB status ✅