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.
This commit is contained in:
jwinterm
2026-07-27 06:40:38 -04:00
parent 1cbbae73b2
commit ed24d43b56
207 changed files with 5023 additions and 8332 deletions
+104 -52
View File
@@ -1,76 +1,128 @@
# Architecture
Wowngeon is a provably-fair, block-timed roguelike that doubles as a **multi-chain on-ramp for the
Smirk wallet**. This is the index for the architecture docs; each subsystem below has its own page.
Wowngeon is a provably fair, block-timed roguelike with crypto entry and payouts. A solo run is a
race against a random block deadline; multiplayer matches run the same engine under a ruleset. The
game also acts as a multi-chain on-ramp for the Smirk wallet.
## The map
This page is the index for the architecture docs. Each subsystem has its own page.
## System map
```
┌──────────── players (browser) ────────────┐
│ index.html · tavern.html · render kit │
┌──────────── players (browser) ─────────────┐
│ index.html · tavern.html · match.html │
│ html/js/render (render kit) │
└───────────────┬───────────────┬────────────┘
Socket.IO │ │ REST (auth, status)
Socket.IO │ │ REST (auth, payments, status)
┌────────────────▼───────────────▼────────────┐
│ src/network/* (socket handlers, chat, │
│ tavern, match, payments) │
├──────────────────────────────────────────────┤
game core │ src/game/* dungeon · rulesets · modes │ → RULESETS.md
money │ src/payments/* providers · wallet-rpc │ → PAYMENTS.md
cosmetics │ src/multiplayer/entitlements + catalog │ → MONETIZATION.md
chains │ src/chain/chainProfile.js │ → chain profiles (below)
chat │ src/network/chat/* local + nostr │ → CHAT_AND_NOSTR.md
└──────────────────────────────────────────────┘
│ src/network/* socket handlers, chat, │
│ tavern, match, payments │
├─────────────────────────────────────────────┤
game core │ src/game/* dungeon · rulesets · modes │ → RULESETS.md
money │ src/payments/* providers · wallet-rpc │ → PAYMENTS.md
cosmetics │ src/multiplayer/entitlements + catalog │ → MONETIZATION.md
chains │ src/chain/chainProfile.js │ → chain profiles, below
chat │ src/network/chat/* local + nostr │ → CHAT_AND_NOSTR.md
└─────────────────────────────────────────────┘
│ │
PostgreSQL Wownero/Monero wallet-RPC + BTCPay/checkout relays
PostgreSQL Wownero/Monero wallet-RPC, BTCPay/checkout gateways
```
## The five pillars (2026-07 "10x" build)
`src/index.js` is the composition root: it builds the Express app and Socket.IO server, wires the
database manager, wallet RPC service, payment config, game mode manager and socket handlers, and
mounts the REST routes in `src/routes/`.
| Pillar | What | Doc |
|--------|------|-----|
| **P1 — Credits & catalog** | Operator-owned cosmetic catalog + tiered unlock rule (free / grant / spend-threshold / tier) | [MONETIZATION.md](MONETIZATION.md) |
| **P2 — Chain profiles** | One registry for the 5 Smirk chains (decimals, block time, adapter family, URI scheme) | see *Chain profiles* below |
| **P3 — Modular payments** | Pluggable payment providers: BTCPay + xmr/wowcheckout (Greenfield) + native Monero, behind one registry | [PAYMENTS.md](PAYMENTS.md) |
| **P4 — Ruleset / PvP** | Gameplay as a data object; race / last-alive / score / co-op are rulesets on the match engine | [RULESETS.md](RULESETS.md) |
| **P5 — Nostr chat** | Global cross-server chat over `relay.smirk.cash`; bridge → per-player Smirk-npub identity | [CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md) |
## Subsystem guide
All five are **behavior-preserving and env-gated** — each stays dormant (native/local defaults) until
its config is set, so `main` is always deployable.
| Area | Where | Doc |
|------|-------|-----|
| Credits, cosmetic catalog, entitlements | `src/multiplayer/entitlements.js`, `src/services/catalogService.js` | [MONETIZATION.md](MONETIZATION.md) |
| Chain constants for the five Smirk chains | `src/chain/chainProfile.js` | below |
| Payment providers and payouts | `src/payments/` | [PAYMENTS.md](PAYMENTS.md) |
| Rulesets and match engine | `src/game/rulesets/`, `src/multiplayer/MatchEngine.js` | [RULESETS.md](RULESETS.md), [MATCH_MODE.md](MATCH_MODE.md) |
| Tavern lobby and spectating | `src/network/tavernManager.js`, `src/multiplayer/Room.js` | [TAVERN_AND_MULTIPLAYER.md](TAVERN_AND_MULTIPLAYER.md) |
| Chat, local and nostr | `src/network/chat/` | [CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md) |
| Client render kit and art packs | `html/js/render/` | [RENDER_PACKS.md](RENDER_PACKS.md) |
| Balance and pacing | `src/sim/`, `src/game/difficultyConfig.js` | [BALANCE_SIM.md](BALANCE_SIM.md), [MULTI_LEVEL.md](MULTI_LEVEL.md) |
| Deployment and operations | `scripts/deploy/` | [DEPLOY.md](DEPLOY.md), [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md), [LOGS_AND_BACKUP.md](LOGS_AND_BACKUP.md) |
| Financial reconciliation and exports | `src/services/` | [FINANCIAL_RECONCILIATION.md](FINANCIAL_RECONCILIATION.md), [FINANCIAL_EVENT_EXPORT.md](FINANCIAL_EVENT_EXPORT.md) |
## Chain profiles (P2)
Optional subsystems are environment-gated and dormant by default: payment gateways register only
when their `*_URL` / `*_STORE_ID` / `*_API_KEY` trio is set, global nostr chat requires
`NOSTR_CHAT_ENABLED=true`, and match rooms require `MATCH_ENABLED=true`. With none of those set the
server runs the native wallet-RPC payment path, local socket chat, and solo play only. The full
environment template is [`src/.env.example`](../src/.env.example).
## Chain profiles
`src/chain/chainProfile.js` is the single source of truth for per-chain constants across BTC, LTC,
XMR, WOW, GRIN: `decimalsFor`, `meanBlockTimeMsFor`, `familyFor` (monero / utxo / mimblewimble),
`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.
XMR, WOW and GRIN: `decimalsFor`, `meanBlockTimeMsFor`, `familyFor` (`monero` / `utxo` /
`mimblewimble`), `uriSchemeFor` and `atomicDivisor` (BigInt).
> **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).
| Chain | Decimals | Mean block time | Family | URI scheme |
|-------|----------|-----------------|--------|------------|
| WOW | 11 | 5 min | monero | `wownero` |
| XMR | 12 | 2 min | monero | `monero` |
| BTC | 8 | 10 min | utxo | `bitcoin` |
| LTC | 8 | 2.5 min | utxo | `litecoin` |
| GRIN | 9 | 1 min | mimblewimble | `grin` |
An unrecognized chain id resolves to a default profile shaped like XMR: 12 decimals, 120000 ms mean
block time, `monero` family, `monero` URI scheme. Callers therefore never crash on a mis-set
`CRYPTO_TYPE`. Adding a chain is one entry in the registry plus a family adapter.
Money math is exact: `src/money/atomic.js` operates on BigInt atomic units parameterized by
decimals, never floats.
## Game balance and pacing
A solo run is bounded by a random deadline drawn from an exponential distribution with mean equal to
the chain's block time, so the house edge is a convolution of the run-completion distribution with
block cadence rather than a number that can be set by hand. `src/sim/` drives the real engine with
headless bots and integrates the deadline out analytically, reporting a measured house win rate per
preset and per network.
`NETWORK_TUNING` in `src/game/difficultyConfig.js` scales dungeon depth to block time. Dungeon size
and monster behaviour come from the difficulty preset; the per-network entry overrides only the
level count:
| Chain | Levels |
|-------|--------|
| GRIN | 1 |
| XMR | 1 |
| LTC | 2 |
| WOW | 2 |
| BTC | 3 |
`NETWORK_TUNING_DISABLED=true` bypasses the override and leaves the preset's own level count in
place. Descending emits a `game_event`, which the client handles in
`html/js/network/socketHandlers.js` to surface level progress. 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).
`html/js/render/` (exposed as `RK`) draws a shared `Scene` model in four techniques: Tiled, ASCII,
Iso and 3D. Art packs are interchangeable data, a pure-canvas FX layer supplies torch and hazard
effects, and the pack and technique available to a player come from the operator-owned cosmetic
catalog. Tiled and ASCII are free; Iso and 3D are premium. See [RENDER_PACKS.md](RENDER_PACKS.md).
## Game balance & pacing
## Provable fairness
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).
`src/game/provablyFair.js` derives each dungeon from a committed seed, `src/game/fairnessVerifier.js`
exposes verification endpoints, and `src/views/verifyPage.js` renders the public verification page so
a player can reproduce a dungeon from its published proof.
The code is heavily JSDoc-commented, while the hand-authored pages here explain subsystem seams and
operator intent. A docs-only generator is intentionally not part of the production dependency lock;
add one in a dedicated documentation toolchain if generated API pages become a release artifact.
## Conventions
## Conventions worth knowing
- **Game id** is a UUID stored in `games.dungeon_seed`; `games.id` is a serial integer. Match game
objects with `WHERE dungeon_seed = $N`, never `WHERE id = $N`.
- **Socket ids are volatile** and change on refresh. Stable identity is the database `user_id` or the
session, never `socket_id`.
- **WOW has 11 decimals** (atomic divisor 1e11), unlike Monero's 12.
- **Cosmetic entitlements are per-pack**, resolved by tier and by lifetime credits purchased in
`src/multiplayer/entitlements.js`. Buying credits does not by itself unlock premium packs.
- **Migrations** live in `src/migrations/NNN_*.sql` and run in numeric order.
- **Tests**: `cd src && npm test` (Jest, `--runInBand`).
- **Game id** is a UUID stored in `games.dungeon_seed`; `games.id` is a serial int. Match game objects
with `WHERE dungeon_seed = $N`, never `WHERE id = $N`.
- **Socket ids are volatile** (change on refresh). For stable identity use the DB `user_id` / session,
never `socket_id`.
- **WOW has 11 decimals** (atomic divisor 1e11), not Monero's 12.
- **Tests**: `cd src && npm test` (jest, `--runInBand`). Migrations live in `src/migrations/NNN_*.sql`.
Source files carry JSDoc for API-level detail; these pages cover the seams between subsystems and
the operator-facing intent behind them.
+93 -53
View File
@@ -1,43 +1,57 @@
# Balance simulation & per-network calibration
# Balance simulation and 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.
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.
## The timing model (why this is subtle)
## Timing model
- **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`:
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.
```
P(survive) = P(block lands after T) = exp(-T / meanBlockTime)
house_win(run) = caught|stuck ? 1 : (1 - exp(-T / meanBlockTime))
```
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.
The random deadline is integrated out analytically — no Monte-Carlo over deadlines needed.
For a run that escapes at wall-clock time `T`:
Block times (`src/chain/chainProfile.js`, `meanBlockTimeMs`): GRIN ~1 min · XMR ~2 · LTC ~2.5 ·
**WOW ~5 (measured on the live daemon)** · BTC ~10.
```
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/`)
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`.
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 + 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. |
| `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:
@@ -46,37 +60,63 @@ node src/sim/simulate.js --runs 200 --bot explorer-greedy --presets normal,casin
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).
`simulate.js` accepts `--runs`, `--bot`, `--presets`, `--nets`, `--cadence`, `--movecap` and
`--json`. Bots are `omniscient-escape`, `omniscient-greedy`, `explorer-escape` and
`explorer-greedy` (default).
## What the sim proved (and the design it forced)
`--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.
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
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.
**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`.
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`)
`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.
`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.
```
GRIN 1 · XMR 2 · LTC 2 · WOW 4 · BTC 8
```
The tuning sets only `levels`. Dungeon size and monster parameters stay at the preset values.
**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).
| 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.
+147 -87
View File
@@ -1,131 +1,191 @@
# Chat & Nostr (P5)
# Chat and Nostr
Chat has two layers: **local delivery** (Socket.IO + Postgres history) and an optional **nostr fan-out**
Chat has two layers: local delivery (Socket.IO plus Postgres history) and an optional nostr fan-out
that carries the same conversation across servers and into the Smirk ecosystem. Both sit behind one
seam so callers never change.
seam, so callers are identical either way.
## The seam
## The provider seam
`src/network/chat/ChatProvider.js` — the abstraction every chat caller uses:
`src/network/chat/ChatProvider.js` is the abstraction every chat caller uses:
```
publish(msg) deliver + (optionally) persist a message
getHistory({scope, limit}) recent messages, oldest → newest
relaySignedEvent(req) deliver a CLIENT-signed nostr event (per-player identity)
publish(msg) deliver and (optionally) persist a message
getHistory({scope, limit}) recent messages, oldest to newest
relaySignedEvent(req) deliver a client-signed nostr event (per-player identity)
initialize() / shutdown()
```
Application concerns (commands, moderation, rate limiting, HTML escaping) stay in the caller;
a provider only delivers and stores.
Two implementations:
- **`SocketChatProvider`** — the default. Global scope → persist via `ChatHistoryManager` + broadcast
(`chat_broadcast`); room scope (tavern) → deliver to that Socket.IO room only.
- **`NostrChatProvider`** — a **decorator** around a local provider. Everything still delivers in-game +
persists; on top of that, global messages fan out to nostr relays, and messages arriving from a relay
are delivered locally-only (never re-published → no echo loops).
- `SocketChatProvider` is the default. Scope `global` persists through `ChatHistoryManager` and
broadcasts `chat_broadcast` to every client; a room scope such as `tavern:<id>` delivers only to
that Socket.IO room and persists nothing. Broadcasts carry a short `publicId` (the user id, or the
first 6 characters of the socket id) rather than the full socket id.
- `NostrChatProvider` decorates a local provider. Every message still delivers in-game and persists;
in addition, global messages fan out to nostr relays, and messages arriving from a relay are
delivered locally only, never re-published, so there are no echo loops.
`buildChatProvider({local, env})` (`src/network/chat/index.js`) picks: with `NOSTR_CHAT_ENABLED` unset it
returns the plain local provider (behavior-preserving); enabled + `scope=global` wraps it in a
`buildChatProvider({local, env})` in `src/network/chat/index.js` chooses between them. With
`NOSTR_CHAT_ENABLED` unset, or with `NOSTR_CHAT_SCOPE` set to anything other than `global`, it
returns the plain local provider unchanged; otherwise it wraps that provider in a
`NostrChatProvider`.
`ChatHandler` constructs the local provider (sharing its own `ChatHistoryManager` instance so ban
checks and stats keep working) and passes it through `buildChatProvider`.
## Unified global chat
The **tavern shares the lobby's global chat** — `TavernManager` takes the lobby's `chatProvider` and
publishes to the `global` scope, and sends the global backlog (`chat_history`) on join. So lobby and
tavern are one conversation with persistent history, and when nostr is on, tavern chat rides the relay
too. Topology is always **client → server → relay**: the server stays the moderation authority (bans,
rate-limit, history in Postgres); the relay is transport + fan-out.
`TavernManager` accepts the lobby's provider as `globalChatProvider` and publishes tavern messages to
the `global` scope, sending the global backlog as `chat_history` on join. Lobby and tavern are
therefore one conversation with persistent history, and when nostr is enabled tavern chat rides the
relay too. When no global provider is injected, the tavern falls back to its own room-scoped,
ephemeral provider.
## Signer models
Topology is always client to server to relay. The server remains the moderation authority (bans,
rate limits, history in Postgres); the relay is transport and fan-out.
`NostrChatProvider` takes an injectable **signer** (`{ pubkey, sign(template) }`). That one object is the
whole difference between the two identity models:
## Identity models
### Phase 1 — bridge (shipped, live)
The server signs every global message with one allowlisted bridge npub; the player's name rides in an
`['n', name]` tag. Works for anyone (even anon), but on nostr it reads as one account talking for
everyone. `createBridgeSigner(NOSTR_BRIDGE_SK)` builds it; the npub must be write-allowlisted on the relay
(see *Relay policy*).
`NostrChatProvider` takes an injectable signer with the contract `{ pubkey, sign(template) }`. That
one object is the whole difference between the two identity models.
### Phase 2 — per-player signing (built; one real-wallet click to confirm)
The **client** signs with the player's own Smirk npub and sends the finished event; the server verifies +
relays it. On nostr it's genuinely *them*.
### Server bridge signer
The server signs every outbound global message with a single key from `NOSTR_BRIDGE_SK`
(`createBridgeSigner`, accepting an `nsec` or 64-hex secret). The player's display name rides in an
`['n', name]` tag. This works for every player, including anonymous ones, but on nostr the whole
server reads as one account. The bridge pubkey must be accepted by the relay's write policy, either
by being a registered npub or by sitting in the relay write allowlist. With no key configured the
signer is `null` and global chat is receive-only.
### Client per-player signing
The client signs with the player's own Smirk npub and sends the finished event; the server verifies
it and relays it unmodified, so on nostr the author is genuinely the player.
```
client server (chatHandler) relay
│ build kind-1 event │ │
│ window.smirk.signNostrEvent │ │
│ ── chat_signed {event} ────► │ rate-limit │
│ │ verifyChatEvent(event, npub) │
│ │ sig ok? id ok? fresh? tag ok? │
│ │ pubkey === session npub? ◄── the │
│ │ impersonation guard │
│ │ escape + relaySignedEvent ──────────►│ (published under the player's npub)
│ ◄──────── chat_broadcast (in-game, all clients) ──────────────────│
client server (ChatHandler) relay
build kind-1 event
window.smirk.signNostrEvent
emit chat_signed {event} --> rate limit
look up session npub
verifyChatEvent(event, npub)
signature, id, freshness, channel tag,
pubkey equals session npub
relaySignedEvent ----------> published under
the player's npub
<------------------- chat_broadcast (all in-game clients)
```
- **`src/utils/verifyChatEvent.js`** — the security core. Same defenses as `verifyNip98Event` (rebuild a
clean event, independently recompute the id, verify schnorr, freshness) **plus** `event.pubkey` must
equal the session's authenticated npub (`users.smirk_public_key`, set at NIP-98 login). A valid signature
by *some other* key is rejected (`pubkey-mismatch`).
- **`chatHandler.handleSignedChatMessage`** — rate-limit → resolve session npub → verify → escape → relay.
- **`NostrChatProvider.relaySignedEvent`** — local delivery + `transport.publish(signedEvent)` (no
re-signing) + echo-dedupe on the event id.
- **`html/js/smirkChatSign.js`** — feature-detected client helper. Signs via `window.smirk.signNostrEvent`
with the Nostr-scope-grant retry (sign first; on `NOT_AUTHORIZED`, `getNostrPublicKey()` once and
retry — same pattern as `SmirkAuth`), so a returning user gets a single approval. Falls back to the
unsigned send when Smirk isn't connected, so wiring it into a send site is always safe. Loaded on
`tavern.html`; the tavern send tries it first. (A real Smirk wallet click is the only step left to
confirm end to end.)
- `src/utils/verifyChatEvent.js` is the security core. It validates shape and types, rebuilds a clean
event from primitives only, independently recomputes the event id, verifies the BIP-340 schnorr
signature, enforces freshness (120 seconds by default), and requires the `t` tag to match the
configured channel tag so the server cannot be used to relay arbitrary events. Critically,
`event.pubkey` must equal the session's authenticated npub (`users.smirk_public_key`, set at NIP-98
login); a valid signature by any other key is rejected with `pubkey-mismatch`. Content is capped at
280 characters.
- `ChatHandler.handleSignedChatMessage` runs the sequence: rate limit, resolve the session's npub
from the database, verify, escape, relay. A session with no linked npub is told to sign in with
Smirk. The display name comes from `display_name` or `username`, falling back to the first 8
characters of the npub.
- `NostrChatProvider.relaySignedEvent` performs local delivery and then `transport.publish(event)`
with no re-signing, marking the event id as seen so the relay round-trip does not deliver it twice.
- `html/js/smirkChatSign.js` is the client helper. It signs through `window.smirk.signNostrEvent` and
emits `chat_signed`. Signing is attempted first; on `NOT_AUTHORIZED` it calls `getNostrPublicKey()`
once to obtain the nostr scope grant and retries, the same pattern as `SmirkAuth`, so a returning
user sees a single approval. It resolves `false` whenever Smirk is unavailable or declines, and the
caller then falls back to the ordinary unsigned send. The helper is loaded on `tavern.html`, whose
send path tries it before `tavern_chat`.
## The tier model (two relays)
The server registers the `chat_signed` socket event in `src/network/socketHandlers.js`.
Everyone signed-in **self-signs as themselves**; the tier is only which relay accepts them, and what perks
come with it (perks + cosmetics, never identity):
## Relays and tiers
| Player | Signs | Lands on |
|--------|-------|----------|
| Anon | nobody | local only |
| Free "pleb" | own npub | `relay.smirk.cash` — open signup + PoW |
| Premium | own npub + ✦ badge | `relay.wowne.ro` — premium-post |
The game publishes each event to every relay in `NOSTR_RELAYS` and lets each relay's policy accept or
reject it; there is no per-player routing or allowlist in game code. Reading is the mirror: subscribe
to all relays and dedupe by event id.
The game publishes each signed event to **all** `NOSTR_RELAYS` and lets each relay's policy accept or
reject — no per-player routing or allowlist in game code. Reading is the mirror (subscribe to all, dedupe
by event id). Premium membership also sets the player's **entitlement tier**, which unlocks cosmetics
(see [MONETIZATION.md](MONETIZATION.md)) — one subscription, two payoffs.
| Player | Signs as | Reaches |
|--------|----------|---------|
| Anonymous | not signed | in-game chat only |
| Signed in with Smirk | own npub | in-game chat plus every relay whose write policy accepts that npub |
## Config (env)
| Var | Meaning |
|-----|---------|
| `NOSTR_CHAT_ENABLED` | master switch (unset ⇒ plain local chat) |
| `NOSTR_CHAT_SCOPE` | `global` (fan out) or `local` (plain provider) |
| `NOSTR_RELAYS` | CSV of relay URLs; transport publishes/subscribes to all |
| `NOSTR_CHAT_TAG` | shared channel topic (default `wowngeon-global`) |
| `NOSTR_BRIDGE_SK` | bridge secret key (nsec/hex); omit ⇒ receive-only |
A premium subscription also sets the player's entitlement tier, which unlocks cosmetics; see
[MONETIZATION.md](MONETIZATION.md). Entitlements are per-pack tier based
(`src/multiplayer/entitlements.js`): buying credits maps to tier 0 and unlocks packs by lifetime
credits purchased, while a subscription tier unlocks the packs at or below that tier.
## Relay policy (smirk-backend-core)
`relay.smirk.cash` is a nostr-rs-relay gated by the Smirk backend's admission oracle. Write policy
`premium-post` = only premium (or write-allowlisted) npubs may publish; `RELAY_WRITE_ALLOWLIST_NPUBS`
(comma-separated npub1…/hex) exempts specific keys — that's how the **bridge npub is registered**. PoW
(`RELAY_INBOUND_POW_BITS`) only gates non-registered authors, so allowlisted/premium npubs post freely.
Reading is open.
Relay admission is configured on the relay side, in the Smirk backend that fronts nostr-rs-relay:
- `RELAY_WRITE_POLICY` is one of `inbox-outbox` (the default), `author-allowlist`, `open`, or
`premium-post`. `premium-post` lets registered users publish wallet kinds and reserves general
posting for premium members; `PREMIUM_ENABLED` requires this policy.
- `RELAY_WRITE_ALLOWLIST_NPUBS` (comma-separated `npub1…` or hex) exempts specific keys from the
policy. This is how a server bridge key is registered.
- `RELAY_INBOUND_POW_BITS` applies a NIP-13 proof-of-work gate to authors that are not registered or
allowlisted, so registered and allowlisted npubs post without PoW.
- Reading is open under every policy.
## Moderation and limits
- Inbound chat is rate limited through the shared `RateLimiter` under the `chat:message` bucket
(12 messages per 10 seconds), keyed by a stable session id plus client IP so reconnecting cannot
reset it. Plain lobby broadcasts add a 2 second per-socket cooldown; tavern occupants have a
900 ms per-occupant cooldown.
- Lobby messages are rejected above 200 characters and HTML-escaped before delivery. Signed messages
are escaped and capped at 200 characters for in-game rendering; the event published to nostr is the
client's original, unmodified.
- Chat bans are checked against `ChatHistoryManager.isUserChatBanned` before a message is published.
The tavern applies the same rate limiter and ban check before anything reaches global chat.
- Remote (relay-sourced) messages are escaped with `escapeChatText`, capped at 200 characters,
attributed from the `n` tag or the first 8 characters of the author pubkey, and rate limited to
`maxRemotePerMin` (120 per minute by default) so a hostile relay cannot flood in-game clients.
- Event ids are deduped through a bounded FIFO of the last 1000 ids, covering both the server's own
echoes and repeats across relays.
- The relay subscription filters on `since = now` at startup, so connecting does not flood clients
with history; local Postgres history covers the past.
- `NostrTransport` degrades to a warning and a no-op if `nostr-tools` or `ws` cannot load, so
enabling nostr chat cannot crash the server.
## Configuration
| Variable | Meaning |
|----------|---------|
| `NOSTR_CHAT_ENABLED` | Master switch. Unset means plain local chat. Accepts `1`, `true`, `yes`, `on`. |
| `NOSTR_CHAT_SCOPE` | `global` (fan out) or anything else (plain local provider). Default `global`. |
| `NOSTR_RELAYS` | Comma-separated relay URLs. Default `wss://relay.smirk.cash`. |
| `NOSTR_CHAT_TAG` | Shared channel topic. Default `wowngeon-global`. |
| `NOSTR_CHAT_KIND` | Event kind. Default `1`. |
| `NOSTR_BRIDGE_SK` | Bridge secret key (`nsec` or 64-hex). Omit for receive-only global chat. |
| `NOSTR_SERVER_ORIGIN` | Value for the `origin` tag on bridge-signed events. Falls back to `SERVER_ID`. |
The full server environment template is `src/.env.example`.
## Loader shim
`nostr-tools` pulls ESM-only deps. `src/utils/nostrLoader.js` `loadNostrTools()` require()s it in prod
Node 22 and falls back to the pre-bundled IIFE under CJS-only Jest. Chat + NIP-98 both use it.
`nostr-tools` pulls ESM-only dependencies. `loadNostrTools()` in `src/utils/nostrLoader.js`
`require()`s the package under Node 22, which natively requires those dependencies, and falls back to
the pre-bundled self-contained IIFE build under a CJS-only test runtime. Chat and NIP-98 auth both
load nostr through it.
## Files
```
src/network/chat/ChatProvider.js seam (+ relaySignedEvent default)
src/network/chat/SocketChatProvider.js local delivery + history
src/network/chat/NostrChatProvider.js decorator: local + nostr fan-out + signed relay
src/network/chat/index.js buildChatProvider (env → provider)
src/network/chat/nostr/NostrTransport.js SimplePool wrapper (multi-relay, degrades to noop)
src/network/chat/ChatProvider.js seam, including the default relaySignedEvent
src/network/chat/SocketChatProvider.js local delivery and history
src/network/chat/NostrChatProvider.js decorator: local plus nostr fan-out plus signed relay
src/network/chat/index.js buildChatProvider (env to provider)
src/network/chat/nostr/NostrTransport.js SimplePool wrapper, multi-relay, degrades to no-op
src/network/chat/nostr/bridgeSigner.js server bridge signer
src/network/chatHandler.js commands, moderation, signed-event handling
src/network/tavernManager.js tavern chat routed into global chat
src/utils/verifyChatEvent.js verify a client-signed event (impersonation guard)
src/utils/escapeChat.js HTML escape and length cap
src/utils/nostrLoader.js nostr-tools loader shim
html/js/smirkChatSign.js client per-player signing helper
```
+64 -34
View File
@@ -1,31 +1,46 @@
# Disposable clone migration gate
`npm run db:migrate:clone` applies the release's normal ordered migrations to an existing,
disposable PostgreSQL clone. It is the migration step for a restored production dump before an
immutable release is eligible for activation. It never creates, restores, drops, audits, or
activates a database.
`npm run db:migrate:clone` applies a release's ordered migrations to an existing, disposable
PostgreSQL clone. It is the migration step for a restored production dump, run before an immutable
release becomes eligible for activation. It never creates, restores, drops, audits, or activates a
database.
The runner is `src/scripts/migrate-disposable-clone.js`. Its only input is the inherited process
environment: it accepts no command-line options, so a target or credential cannot land in argv or
shell history.
## Hard target contract
The runner refuses to open a connection unless all of these conditions hold:
- `DB_HOST` is explicit loopback (`127.0.0.1`, `::1`, or `localhost`) or the standard local Unix
socket directory `/run/postgresql` or `/var/run/postgresql`;
- `DB_NAME` starts with `monerogue_`, `wownerogue_`, or `wowngeon_` and contains a complete
underscore-delimited `clone`, `restore`, or `canary` token;
- the name contains no live marker beginning with `prd`, `prod`, `production`, `live`, `mainnet`,
`master`, `primary`, `current`, or `active`;
- `DB_HOST` is explicit loopback (`127.0.0.1`, `::1`, or `localhost`) or one of the standard local
Unix socket directories `/run/postgresql` and `/var/run/postgresql`;
- `DB_NAME` matches `^[a-z][a-z0-9_]{2,62}$`, its first underscore-delimited token is `monerogue`,
`wownerogue`, or `wowngeon`, and some later token is exactly `clone`, `restore`, or `canary`;
- no token of `DB_NAME` begins with a live marker: `active`, `current`, `live`, `mainnet`, `master`,
`primary`, `prd`, `prod`, or `production`;
- `CLONE_MIGRATION_EXPECT_DATABASE` exactly equals `DB_NAME`;
- `CLONE_MIGRATION_CONFIRM` exactly equals `MIGRATE_DISPOSABLE_CLONE:<DB_NAME>`;
- `DB_PORT` and `DB_USER` are explicit, `DATABASE_URL` is absent, and the process receives no
command-line arguments.
- `DB_PORT` is an explicit integer port from 1 through 65535 and `DB_USER` is an explicit simple
role name matching `^[a-z_][a-z0-9_]{0,62}$`;
- `DATABASE_URL` is unset or empty, and the process receives no command-line arguments.
After connecting, the runner checks PostgreSQL's `current_database()` before any DDL. It then calls
the same `DatabaseManager.runMigrations()` used by application startup: lexical filename order,
the `schema_migrations` ledger, and one transaction per newly applied file. Finally, it requires the
ledger to equal the complete repository migration manifest, in order, with no missing or extra
entry. The manifest is discovered from the candidate release and must be contiguous from 001; the
current floor is migration 043. A successful current release reports:
The migration manifest is then discovered from the running release's `src/migrations` directory.
Every `.sql` entry must be a regular file named `NNN_name.sql`, the ordinals must be contiguous
from `001`, and the highest ordinal must be at least `043`.
Only after those static gates pass does the runner connect. It opens a single-connection pool
(`max: 1`, five-second connection and idle timeouts, `application_name`
`wowngeon-disposable-clone-migrations`) and reads PostgreSQL's own `current_database()` before any
DDL, requiring an exact match with `CLONE_MIGRATION_EXPECT_DATABASE`.
Migrations then run through the same `DatabaseManager.runMigrations()` that application startup
uses: lexical filename order, a `schema_migrations` ledger, and one transaction per newly applied
file that both executes the SQL and records the ledger row. Afterwards the runner requires the
ledger to equal the repository manifest exactly: same count, same filenames, same order, ending at
the same latest migration.
A successful run reports:
```text
clone_migration_status=ok
@@ -34,12 +49,16 @@ migration_count=43
latest_migration=043_durable_solo_restart_snapshots.sql
```
Any refusal or verification failure exits non-zero after printing a single line prefixed `REFUSED:`
(target or manifest contract violation) or `FAILED:` (any other error). The `FAILED:` path prints no
server diagnostics, connection object, or environment value.
## Invocation
Create and restore the clone with the reviewed backup procedure first. Keep the target variables in
a protected operator-owned environment file and database credentials in a mode-0600 `PGPASSFILE`;
do not type a password, connection URL, or target as a command argument. For example, the inherited
non-secret target environment is:
a protected operator-owned environment file and database credentials in a mode-0600 `PGPASSFILE`.
Do not pass a password, connection URL, or target as a command argument. A non-secret target
environment looks like this:
```dotenv
DB_HOST=127.0.0.1
@@ -54,26 +73,37 @@ CLONE_MIGRATION_CONFIRM=MIGRATE_DISPOSABLE_CLONE:monerogue_restore_20260721t2200
Database names use lowercase letters, digits, and underscores only. Normalize a release ID into a
separate safe tag rather than copying a hyphenated artifact ID into `DB_NAME`.
Load that protected environment through the reviewed service/wrapper, change into the extracted
Load that protected environment through the reviewed service or wrapper, change into the extracted
candidate's `src` directory, and run only:
```bash
npm run db:migrate:clone
```
Do not append `--` arguments. The runner never logs host, role, password, connection URL, or
`PGPASSFILE` contents. `DB_PASSWORD` is supported only for an already-protected inherited service
environment and is kept in memory; `PGPASSFILE` is preferred.
Do not append `--` arguments. The runner logs no host, role, password, connection URL, or
`PGPASSFILE` contents. `DB_PASSWORD` is honoured when an already-protected service environment
supplies it and is held in memory only; `PGPASSFILE` or `PGPASSWORD` is preferred, and neither
requires the application-specific variable.
## Required follow-on gates and limits
## Required follow-on gates
Run `scripts/deploy/financial-audit.sql` and the restored-data constraint validation gate against
the same clone after migration. Compare the result with the pre-migration audit, then test the
candidate and predecessor against the migrated clone as required by the fleet runbook.
Run these against the same clone after migration:
- `scripts/deploy/financial-audit.sql`, compared against the pre-migration audit of the same data;
- `scripts/deploy/financial-constraint-validate-restore.sql`, the restored-data constraint
validation gate described in
[FINANCIAL_CONSTRAINT_VALIDATION.md](./FINANCIAL_CONSTRAINT_VALIDATION.md).
Then test both the candidate and its predecessor against the migrated clone, as described in
[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). The deploy SQL lives outside the runtime artifact and
is run from the repository against the clone.
## Limits
This runner does not prove that a database with a disposable-looking name is operationally
disposable, does not prevent another application process from connecting, and does not acquire an
activation-wide drain lock. The operator must create the clone from the intended fresh dump, keep
it outside public service configuration, and destroy it separately after the release decision. A
successful result authorizes only the next audit/test gate; it does not authorize a symlink switch,
wallet action, payout, or traffic restoration.
disposable, does not prevent another process from connecting to it, and does not acquire an
activation-wide drain lock. The operator must create the clone from the intended fresh dump, keep it
outside public service configuration, and destroy it separately after the release decision.
A successful result authorizes only the next audit and test gate. It does not authorize a symlink
switch, wallet action, payout, or traffic restoration.
+194 -125
View File
@@ -2,34 +2,36 @@
This guide covers deploying Wownerogue to a production environment.
For evidence about the release currently serving `play.wowne.ro`, see the dated
[production-state record](PRODUCTION_STATE_2026-07-25.md). That record is not a substitute for this
runbook or authorization for another activation.
## Operated profiles versus independent deployments
Such Software (`apps@such.software`) operates only `play.wowne.ro` (Wownero mainnet
pay-for-credits leaderboard/prestige, no prize/payout/cash-out and not marketed as gambling) and
`monerogue.app` (Monero stagenet direct-entry-only **NO REAL VALUE** 2×/3× solo test gambling
mechanics, with purchased-credit entry and crypto-match payouts off).
Classification of any product under applicable law requires jurisdiction-specific advice.
Their reviewed environment templates set `OPERATED_PRODUCT_PROFILE`, so preflight/startup rejects
scope drift. Independent MIT deployments must leave that variable unset and identify their actual
operator.
Such Software (`apps@such.software`) operates only `play.wowne.ro` (Wownero mainnet free play plus
pay-for-credits leaderboard/prestige, with no prize, payout, or cash-out, and not marketed as
gambling) and `monerogue.app` (Monero stagenet direct entry only, **NO REAL VALUE** test coins, with
single-player 2x/3x test gambling mechanics, purchased-credit entry disabled, and crypto match
payouts off). Both profiles are defined in `src/config/operatedProductProfiles.js` and selected by
`OPERATED_PRODUCT_PROFILE`; preflight and normal startup reject any network, identity, or
economic-scope drift from the selected profile. Classification of any product under applicable law
requires jurisdiction-specific advice.
MIT rights are subject to retaining the copyright and permission notice. The software is provided
“AS IS”, without warranty, and the code/docs are not legal advice or compliance approval. Each
third-party operator is solely responsible for its deployment, legal obligations, funds, players,
claims, and support; Such Software neither endorses nor accepts responsibility for it.
Independent MIT deployments leave `OPERATED_PRODUCT_PROFILE` unset and identify their actual
operator. MIT rights are subject to retaining the copyright and permission notice. The software is
provided "AS IS", without warranty, and the code and documentation are not legal advice or
compliance approval. Each third-party operator is solely responsible for its deployment, legal
obligations, funds, players, claims, and support. Such Software neither endorses nor accepts
responsibility for it.
---
## Prerequisites
- Node.js 22.x LTS or later
- PostgreSQL 12+
- Node.js 22.x LTS or later (`src/package.json` requires `node >=22`, `npm >=10`)
- PostgreSQL 12 or later
- Wownero or Monero wallet-rpc running and synced
- A domain with SSL certificate (via reverse proxy)
- A domain with a TLS certificate, terminated by a reverse proxy
The full environment template is `src/.env.example`. Operated profile templates are
`src/.env.mainnet.example` and `src/.env.stagenet.example`; `src/.env.match.example` covers race
mode. The five-line `.env.example` at the repository root is not a configuration reference.
---
@@ -39,16 +41,17 @@ claims, and support; Such Software neither endorses nor accepts responsibility f
- [ ] Configure TLS via reverse proxy (Nginx/Caddy)
- [ ] Set secure database credentials
- [ ] Configure wallet-rpc with authentication
- [ ] Set `ADMIN_API_KEY` for admin endpoints
- [ ] Set `ADMIN_API_KEY` (production requires a non-placeholder secret of at least 32 characters)
- [ ] Run `npm run preflight` with the final environment
- [ ] Keep `PAYOUTS_ENABLED=false` on real-money prestige instances
- [ ] Complete a stagenet payment and payout before enabling a payout instance
- [ ] Firewall: allow Node 3000/3001 only from the reverse proxy; keep wallet/daemon RPC loopback-only
- [ ] Protect `/admin.html` with basic auth
- [ ] Firewall: allow Node 3000/3001 only from the reverse proxy; keep wallet and daemon RPC
loopback-only
- [ ] Protect the admin page (served at both `/admin` and `/admin.html`) with basic auth
- [ ] Set up database backups (see [LOGS_AND_BACKUP.md](./LOGS_AND_BACKUP.md))
- [ ] Configure log rotation
- [ ] Run database migrations
- [ ] If 3D is offered, provision the gitignored runtime GLBs and verify library/model requests
- [ ] If 3D is offered, provision the gitignored runtime GLBs and verify library and model requests
stay same-origin
---
@@ -56,7 +59,7 @@ claims, and support; Such Software neither endorses nor accepts responsibility f
## Create Dedicated User
Create an isolated, non-login service identity. Release directories and the `current` link remain
root-owned; the service identity receives read/traverse access only and must never own its code:
root-owned; the service identity receives read and traverse access only and never owns its code:
```bash
sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/www/wownerogue --no-create-home wownerogue
@@ -74,16 +77,22 @@ sudo install -d -m 0750 -o wownerogue -g wownerogue /var/log/wownerogue
Build the runtime artifact from a clean, reviewed commit as described in
[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). The production host needs no Git checkout, deploy key,
or write-capable source identity. The reviewed fleet
`playbooks/wowngeon-stage-candidate.yml` role transfers exactly one tarball to a newly selected
root-owned mode-0700 candidate inbox and compares its remote SHA-256 with the independently
recorded literal digest. Follow the fleet Wowngeon runbook; the role is a one-shot custody boundary,
not deployment.
or write-capable source identity.
The executable extraction/activation boundary lives only in the separately reviewed fleet
The artifact contains exactly `LICENSE`, `html`, and `src`, minus environment files, tests, the
balance simulator, and the operator-only scripts under `src/scripts`. Documentation, service units,
firewall and backup scripts, and root-level operator tooling are deliberately excluded: operational
artifacts keep their own pinned provenance in the source and fleet repositories rather than becoming
part of the application runtime because they share a Git commit.
The fleet `playbooks/wowngeon-stage-candidate.yml` role transfers exactly one tarball to a newly
selected root-owned mode-0700 candidate inbox and compares its remote SHA-256 with the
independently recorded literal digest. It is a one-shot custody boundary, not deployment.
The executable extraction and activation boundary lives only in the separately reviewed fleet
repository as the default-closed `wowngeon_release_activate` role. This application checkout cannot
attest which fleet revision is installed or authorize its execution. Follow the hash-pinned fleet
runbook and literal receipt contract; do not manually extract beneath `releases/`, install
runbook and literal receipt contract. Do not manually extract beneath `releases/`, install
dependencies there, change `current`, restart an application, or run migrations merely because a
blob has arrived.
@@ -103,11 +112,11 @@ node --version
npm --version
```
The reviewed fleet extraction/activation role installs exactly the locked production graph in its
The fleet extraction and activation role installs exactly the locked production graph in its
private, writable staging directory with
`npm ci --omit=dev --ignore-scripts --no-audit --no-fund`, verifies it with
`npm ls --omit=dev`, and seals the finished release root-owned and non-writable before it can be
selected. Never make a release tree writable by `wownerogue`. Online advisory lookup is a separate
selected. Never make a release tree writable by `wownerogue`. Online advisory lookup is a separate,
explicitly authorized source-review operation; never run `npm audit fix` in staging or on a live
release.
@@ -123,11 +132,11 @@ sudo chmod 0640 /etc/wownerogue/app.env
```
Do not use recursive ownership changes on `/var/www/wownerogue`; they can hand the service process
write access to immutable code or the rollback selector. The reviewed fleet role must verify
every individual release's ownership and modes before activation.
write access to immutable code or the rollback selector. The fleet activation role verifies every
individual release's ownership and modes before activation.
**Database ownership** - the application currently runs its own migrations at startup, so its
database role must own the database/schema (or otherwise retain DDL rights). Create it this way:
**Database ownership.** The application runs its own migrations at startup, so its database role
must own the database and schema, or otherwise retain DDL rights. Create it this way:
```sql
-- Create role with minimal privileges
@@ -136,34 +145,47 @@ CREATE DATABASE wownerogue OWNER wownerogue;
```
Do not revoke schema `CREATE` from this role while application startup owns migration execution;
that makes a fresh release fail partway through startup. A separate one-shot migrator role is the
follow-up path if runtime DDL privileges need to be removed.
that makes a fresh release fail partway through startup.
---
## Database Setup
### Run Migrations
Database migrations run automatically, in filename order, inside one transaction per file and are
recorded in `schema_migrations`. Do not apply migration files directly with `psql`: bypassing the
ledger makes startup attempt them again. Before a release, take a restricted PostgreSQL backup and
test the complete migration set against a restored copy of each production database using the
fail-closed [disposable clone migration gate](CLONE_MIGRATIONS.md).
Migrations in `src/migrations` run automatically at startup in filename order. Each file is applied
and recorded in the `schema_migrations` ledger inside one transaction, so a migration can never be
applied but unrecorded. Do not apply migration files directly with `psql`: bypassing the ledger
makes startup attempt them again.
The migration ledger does not prove that rows predating a `NOT VALID` constraint are clean. Run
the read-only historical audit and the rollback-only native validation proof described in
[`FINANCIAL_CONSTRAINT_VALIDATION.md`](FINANCIAL_CONSTRAINT_VALIDATION.md) for each restored
database. The native `VALIDATE CONSTRAINT` gate deliberately refuses live database names.
Before a release, take a restricted PostgreSQL backup and test the complete migration set against a
restored copy of each production database using the fail-closed
[disposable clone migration gate](CLONE_MIGRATIONS.md).
The migration ledger does not prove that rows predating a `NOT VALID` constraint are clean. Run the
read-only historical audit and the rollback-only native validation proof described in
[FINANCIAL_CONSTRAINT_VALIDATION.md](FINANCIAL_CONSTRAINT_VALIDATION.md) for each restored database.
The native `VALIDATE CONSTRAINT` gate refuses live database names.
If the Wownero mainnet service will export accounting events, review and configure the durable
[financial-event outbox](FINANCIAL_EVENT_EXPORT.md) before cutover. Monero stagenet export must
remain unset: those no-value test events are marked ignored locally.
[financial-event outbox](FINANCIAL_EVENT_EXPORT.md) before cutover. Monero stagenet export stays
unset: those no-value test events are marked ignored locally.
Install the reviewed `wowngeon-db-backup.sh`, `.service`, and `.timer` through the hash-pinned fleet
operations change—not from the runtime artifact—and create
`/var/backups/wowngeon/daily` as `postgres:postgres` mode `0700`, then enable the timer. Each run
writes custom-format dumps atomically, verifies their catalogs with `pg_restore --list`, and records
a SHA-256 sidecar. Configure retention/remote replication according to the operator's recovery
policy; the supplied job deliberately does not delete backups.
### Backups
Install `wowngeon-db-backup.sh`, `.service`, and `.timer` through the hash-pinned fleet operations
change rather than from the runtime artifact, create `/var/backups/wowngeon/daily` as
`postgres:postgres` mode `0700`, then enable the timer.
Each run writes custom-format dumps atomically, verifies their catalogs with `pg_restore --list`,
chmods them `0600`, and records a SHA-256 sidecar. The script refuses any backup root other than
`/var/backups/wowngeon/daily` and refuses a missing root or a symlink.
Dumps and sidecars older than `BACKUP_RETENTION_DAYS` (default 14) are deleted, and interrupted
temporary files older than one day are cleaned up. This bounds a same-disk directory so it cannot
fill `/`; it is an operational convenience, not the retention archive. Long-horizon history belongs
in a separate, independently encrypted offsite vault configured according to the operator's recovery
policy.
### Database Reset (Development Only)
@@ -176,25 +198,30 @@ npm run db:create # Recreate schema
## systemd Service
Install reviewed application, wallet, backup, and firewall artifacts only through the hash-pinned
fleet operations change; the runtime artifact intentionally has no service units. Application
units read secrets from `/etc/wownerogue/app.env` or `/etc/monerogue/app.env` plus the narrow
wallet-RPC environment, never from an immutable release directory. They run `npm run preflight`
before every start and use `/var/www/<instance>/current/src` so rollback is an atomic symlink
switch.
Install application, wallet, backup, and firewall units only through the hash-pinned fleet
operations change; the runtime artifact has no service units. Application units read secrets from
`/etc/wownerogue/app.env` or `/etc/monerogue/app.env` plus the narrow wallet-RPC environment, never
from an immutable release directory. They run `scripts/preflight.js` as `ExecStartPre` before every
start and use `/var/www/<instance>/current/src` as the working directory, so rollback is an atomic
symlink switch.
For the two Such Software services, do not replace this boundary with manual `install`, `systemctl`,
or symlink commands. Follow the default-closed wallet candidate/promotion, candidate validation,
or symlink commands. Follow the default-closed wallet candidate and promotion, candidate validation,
and one-instance activation procedures in [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md) and the
hash-pinned `~/src/such-fleet/RUNBOOK-wowngeon.md`. Independent MIT operators must design and review
their own equivalent control plane.
hash-pinned `such-fleet/RUNBOOK-wowngeon.md`. Independent MIT operators design and review their own
equivalent control plane.
The systemd hardening options:
- `NoNewPrivileges` - prevents privilege escalation
- `ProtectSystem=strict` - mounts filesystem read-only except allowed paths
- `ProtectHome=yes` - hides /home, /root, /run/user
- `PrivateTmp=yes` - isolates /tmp
- `ReadWritePaths` - whitelists the wallet state directory in wallet-RPC units
The reviewed units drop the ambient environment with a broad `UnsetEnvironment` list (loader,
proxy, `PG*`, and `npm_config_*` variables among others) and apply these systemd hardening options:
- `NoNewPrivileges` prevents privilege escalation
- `ProtectSystem=strict` mounts the filesystem read-only except allowed paths
- `ProtectHome=yes` hides `/home`, `/root`, and `/run/user`
- `PrivateTmp=yes` isolates `/tmp`
- `CapabilityBoundingSet=` and `AmbientCapabilities=` remove all capabilities
- `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6` and `SystemCallArchitectures=native` narrow the
kernel surface
- `ReadWritePaths` whitelists the wallet state directory in wallet-RPC units
---
@@ -205,19 +232,15 @@ If using Nginx Proxy Manager:
1. **Add Proxy Host**
- Domain Names: `yourdomain.com`
- Scheme: `http`
- Forward Hostname/IP: Your server's LAN IP (e.g., `192.168.1.100`)
- Forward Hostname/IP: your server's LAN IP (for example `192.168.1.100`)
- Forward Port: `3000`
- Enable "Websockets Support" toggle
Record the proxy's fixed source address in `/etc/wowngeon/firewall.env` as
`NPM_SOURCE_IPV4=<address>`. Direct access to 3000/3001 must be rejected; this also makes the
single-hop `TRUST_PROXY_HOPS=1` setting safe.
- Enable the "Websockets Support" toggle
2. **SSL Tab**
- Request a new SSL certificate
- Enable "Force SSL"
3. **Advanced Tab** - Paste this config for WebSocket support:
3. **Advanced Tab**: paste this config for WebSocket support:
```nginx
location /socket.io/ {
@@ -232,17 +255,37 @@ location /socket.io/ {
}
```
Record the proxy's fixed source address in `/etc/wowngeon/firewall.env` as
`NPM_SOURCE_IPV4=<address>`. `wowngeon-firewall.sh` rejects every non-loopback TCP connection to
ports 3000 and 3001 that does not originate from that address. Because direct access is rejected,
the single-hop `TRUST_PROXY_HOPS=1` setting is safe. The application clamps `TRUST_PROXY_HOPS` to
the range 1 through 8.
---
## Browser Asset Origin
Three.js is an exact production dependency and is served from the application origin at
`/vendor/three/<version>`, with the version embedded in the URL so an immutable cache cannot survive
an upgrade to different bytes. `RENDERER_CDN_ENABLED` defaults to `false`, which keeps the CSP from
granting third-party CDN scripts the same privileges as the game and payment UI. Setting it to
`true` in production logs a warning and widens `script-src` and `connect-src` to jsDelivr.
Generated 3D avatar models under `html/assets/generated/` are gitignored and are not part of the
release artifact. Provision them separately on any instance that offers the 3D renderer.
---
## Updating the Deployment
Run the full suite from the clean source commit, then build and verify the runtime artifact using
[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). Use the fleet blob-staging playbook, then proceed only
through the reviewed, hash-pinned `wowngeon_release_activate` role after its clone, wallet,
accounting, drain, predecessor, and receipt gates pass. That role—not an application service
identity or a manual shell session—builds a new immutable
`/var/www/<instance>/releases/<release-id>`, installs the locked graph, verifies the clone/preflight
evidence, seals ownership, and atomically selects `current`.
through the hash-pinned `wowngeon_release_activate` role after its clone, wallet, accounting, drain,
predecessor, and receipt gates pass. That role, not an application service identity or a manual
shell session, builds a new immutable `/var/www/<instance>/releases/<release-id>`, installs the
locked graph, verifies the clone and preflight evidence, seals ownership, and atomically selects
`current`.
Never run `git pull`, `npm install`, or `npm audit fix` inside the active release. Keep the previous
release and database backup until the new version has passed public health, WebSocket,
payment-intake, and (stagenet only) payout smoke tests.
@@ -251,7 +294,7 @@ payment-intake, and (stagenet only) payout smoke tests.
## Multi-Instance Deployment
To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on port 3001):
To run multiple instances, for example Wownero on port 3000 and Monero stagenet on port 3001:
### Directory Structure
@@ -275,68 +318,84 @@ To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on por
| `PRIMARY_WALLET_ENDPOINT` | http://127.0.0.1:34570 | http://127.0.0.1:38083 |
| `PRIMARY_RPC_ENDPOINT` | http://127.0.0.1:34568 | http://127.0.0.1:38081 |
### Daemon redundancy and graceful degradation
### Separate systemd Service
Install the hash-pinned fleet copy of `monerogue.service`; it is absent from the runtime artifact.
Do not hand-maintain a second, weaker unit. The reviewed template carries the same preflight,
graceful-stop, immutable-release path, and systemd sandbox as the mainnet service.
---
## Daemon Redundancy and Graceful Degradation
`RPC_ENDPOINTS` takes any number of daemons, comma or whitespace separated, in **preference order**:
```
RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568,http://10.42.1.41:34568
RPC_ENDPOINTS=http://127.0.0.1:34568,http://192.0.2.40:34568,http://192.0.2.41:34568
```
`PRIMARY_RPC_ENDPOINT` / `FALLBACK_RPC_ENDPOINT` still work and are appended to the list. The list
is deduplicated — note that the stock configuration sets primary and fallback to the *same* host, so
by default there is exactly one node and no redundancy at all. Add at least one genuinely separate
daemon before relying on failover.
`PRIMARY_RPC_ENDPOINT` and `FALLBACK_RPC_ENDPOINT` remain supported and are appended to an explicit
list. When no list is given, primary (or `http://127.0.0.1:34568`) and fallback form the list on
their own. The result is deduplicated, so a stock configuration that points primary and fallback at
the same host yields exactly one node and no redundancy. Add at least one genuinely separate daemon
before relying on failover.
Behaviour:
- one logical RPC call tries **every** configured node before it fails;
- each node must pass the same chain-identity check before it may answer, so failover can never
- each node must pass the same chain-identity check before it may answer, so failover cannot
silently serve a different chain's data;
- after failing over, the preferred node is re-tested every `RPC_PREFERRED_RETRY_MS` (default 60s,
minimum 1s), so recovery does not need a restart;
- when **no** node answers, the service reports unhealthy and throws. `getBlockCountStrict()` —
used by fairness and match seeding — never substitutes a cached height.
- after failing over, the preferred node is re-tested every `RPC_PREFERRED_RETRY_MS` (default
60000, minimum 1000), so recovery does not need a restart;
- when **no** node answers, the service reports unhealthy and throws. `getBlockCountStrict()`, used
by match fairness and seeding, never substitutes a cached height.
**Entries are refused while no node is reachable.** A run's lifetime is counted in blocks and its
entry block comes from the last successful poll; admitting a game during an outage anchors it to an
already-past block and it is killed as a timeout on the first recovered poll. The player is told
`Can't reach a <CHAIN> node right now…` and nothing is charged. Credits are not consumed, because
the gate runs before any spend.
entry block comes from the last successful poll, so a game admitted during an outage is anchored to
an already-past block and is killed as a timeout on the first recovered poll. The player receives a
`chain_unavailable` event reading `Can't reach a <CHAIN> node right now, so entries are paused.
Nothing was charged`, and nothing is charged: the gate runs before any spend, so credits are not
consumed. The gate fires only when the chain health check explicitly reports false, so simulated
blocks and runtimes without the probe are unaffected.
**The wallet is deliberately NOT failed over.** Two `wallet-rpc` processes serving one wallet file
can build transactions from the same outputs and corrupt the wallet cache — a standby wallet is a
custody decision for the operator, not something the app may do on its own. Daemons are read-only
and interchangeable; wallets are neither. When the wallet is unreachable the server declines paid
intake with `Payments are temporarily unavailable…`, and credits and free play keep working.
### Separate systemd Service
Install the hash-pinned fleet copy of `monerogue.service`; it is intentionally absent from the
runtime artifact. Do not hand-maintain a second, weaker unit. The reviewed template contains the
same preflight, graceful-stop, immutable-release path, and systemd sandbox as the mainnet service.
**The wallet is deliberately not failed over.** Two `wallet-rpc` processes serving one wallet file
can build transactions from the same outputs and corrupt the wallet cache, so a standby wallet is a
custody decision for the operator rather than something the application may take on its own.
Daemons are read-only and interchangeable; wallets are neither. When the wallet is unreachable the
server declines paid intake with a `payment_unavailable` event reading `Payments are temporarily
unavailable`, and credits and free play keep working.
---
## Wallet Output Management
Wownero (and Monero) lock change outputs for several blocks after spending. If the wallet has only one large output and multiple payouts fire in quick succession, the second payout will fail because the change output from the first is still locked.
Wownero and Monero lock change outputs for several blocks after spending. If the wallet holds one
large output and multiple payouts fire in quick succession, the second payout fails because the
change output from the first is still locked.
**Solution**: Pre-split wallet outputs into many smaller ones. The game server includes a CLI tool for this:
The remedy is to pre-split wallet outputs into many smaller ones. `scripts/splitOutputs.js` is an
operator tool in the source repository, not part of the release artifact; run it from a source
checkout whose `src/.env` points at the wallet RPC.
```bash
# Preview what would happen (no transaction sent)
node scripts/splitOutputs.js --amount 10 --count 30 --dry-run
# Show the current output breakdown, including locked and unlocked status
node scripts/splitOutputs.js --status
# Split into 30 outputs of 10 WOW each (requires ~300 WOW + fees)
# Split into 30 outputs of 10 WOW each (requires 300+ WOW unlocked, plus fees)
node scripts/splitOutputs.js --amount 10 --count 30
```
This creates 30 independently-spendable outputs, allowing up to 30 concurrent payouts before any output locking becomes an issue.
Defaults are 10 WOW and 20 outputs; `--endpoint` overrides `PRIMARY_WALLET_ENDPOINT`. Thirty
independently spendable outputs allow up to thirty concurrent payouts before output locking becomes
an issue.
**When to re-split**: As payouts go out, outputs get consumed and consolidated via change. Periodically check the wallet and re-split when the number of spendable outputs drops low. The server also batches payouts that happen within 5 seconds of each other into a single transaction to conserve outputs.
**When to re-split.** Outputs are consumed and consolidated back into change as payouts go out.
Check the wallet with `--status` periodically and re-split when the number of spendable outputs
drops low. The server also debounces payouts by 5 seconds so wins landing close together are batched
into one transfer, which conserves outputs. A slower interval sweep
(`payouts.processing.batchInterval`, default 300 seconds) picks up anything left pending.
**Recommended setup**: After initial wallet funding, run the split script. For a game with typical traffic, 20-30 outputs of 10 WOW each provides good concurrency headroom.
For typical traffic, 20 to 30 outputs of 10 WOW each provides good concurrency headroom.
---
@@ -349,22 +408,28 @@ systemctl status wownerogue
journalctl -u wownerogue -f # Follow logs
```
### Health Endpoint
### Health Endpoints
```bash
curl http://localhost:3000/health/live
curl --fail http://localhost:3000/health/ready
```
`/health/live` checks the process. `/health/ready` returns 503 until PostgreSQL, the chain daemon,
and (for paid instances) wallet RPC are ready. Public probes do not expose wallet balances or RPC
endpoints; those remain in the authenticated admin dashboard.
`/health/live` reports process liveness only. `/health` returns the full public snapshot, and
`/health/ready` returns the same body with HTTP 503 until PostgreSQL, the chain daemon, and (for
paid instances) wallet RPC are ready. All three send `Cache-Control: no-store`.
Public probes omit wallet balances, RPC addresses, secrets, memory details, and abuse thresholds;
those remain in the authenticated admin stats endpoints. An optional accounting sink is reported as
sanitized informational data and never makes gameplay readiness fail.
### Admin Dashboard
Access `/admin.html` with your `ADMIN_API_KEY` for:
The admin page is served at `/admin` and `/admin.html`. Admin API endpoints require an `X-Admin-Key`
header matching `ADMIN_API_KEY`, and return 503 when that variable is unset. The dashboard covers:
- Wallet balance and connection status
- Pending/failed payouts
- Pending and failed payouts
- Game statistics
- User search
@@ -386,6 +451,10 @@ sudo -u wownerogue bash -c 'set -a; . /etc/wownerogue/app.env; set +a; npm run p
psql -h localhost -U wownerogue -d wownerogue -c "SELECT 1;"
```
Preflight validates operator configuration without connecting to PostgreSQL or either RPC. It
catches missing secrets, ambiguous payout flags, simulated blocks in production, missing wallet or
daemon endpoints, and operated-profile scope drift.
### Wallet RPC Issues
```bash
@@ -403,4 +472,4 @@ If migrations fail, check the `schema_migrations` ledger:
SELECT filename, applied_at FROM schema_migrations ORDER BY filename;
```
Migrations are tracked by name to prevent duplicate execution.
Migrations are tracked by filename to prevent duplicate execution.
+194 -192
View File
@@ -1,117 +1,159 @@
# Two-instance deployment runbook
Two independent instances of the same secured codebase, behind Nginx Proxy Manager (NPM):
Two independent instances of the same codebase run behind Nginx Proxy Manager (NPM), each with its
own service identity, database, environment file, wallet, and chain.
| Instance | Domain | Port | Network | Money | Payouts | Leaderboards |
|----------|--------|------|---------|-------|---------|--------------|
| **wownerogue** (existing) | play.wowne.ro | 3000 | Wownero **mainnet** | purchased service credits | **OFF, always** | Pleb + paid prestige |
| **monerogue** (new) | monerogue.app | 3001 | Monero **stagenet only** | test coins — **NO REAL VALUE** | **ON for 2×/3× solo only (target; wallet-gated)** | Pleb + Hall of Champions |
| Instance | Domain | Port | Chain | Entry | Payouts | Leaderboards |
|----------|--------|------|-------|-------|---------|--------------|
| `wownerogue` | play.wowne.ro | 3000 | Wownero mainnet | free play and purchased service credits | disabled | Pleb and paid prestige |
| `monerogue` | monerogue.app | 3001 | Monero stagenet | free play and direct test-coin entry | enabled, direct solo only | Pleb and Hall of Champions |
Such Software (`apps@such.software`) operates only these two domains and these exact scopes.
`play.wowne.ro` is pay-for-credits prestige with no prize/payout/cash-out and is not marketed as
gambling; legal classification depends on applicable law. `monerogue.app` is a conspicuous
no-real-value test gambling surface; never send mainnet XMR and never enable crypto-match payouts.
Stagenet payouts are a technical test surface, not authorization to offer real-money wagering.
`ALLOW_MAINNET_PAYOUTS=true` is only an engineering interlock; obtain jurisdiction-specific legal,
age/access-control, responsible-play, and operational review before attaching redeemable value.
NPM forwards both domains to these ports and terminates TLS, so this runbook contains no
reverse-proxy configuration.
NPM already forwards both domains to these ports + handles TLS — no reverse-proxy work here.
## Scope and legal boundary
> ⚠️ This is an operator runbook, not a record of actions already executed. Deploy only a reviewed,
> tagged release after the full test suite and both preflights pass.
Such Software operates only these two domains, and only within the scopes encoded in
`src/config/operatedProductProfiles.js`.
---
`play.wowne.ro` sells non-redeemable service credits for leaderboard and prestige play. It offers no
prize, payout, or cash-out and is not offered or marketed as gambling. Classification depends on
applicable law.
## Step 0 — Stage an immutable reviewed release
`monerogue.app` is a no-real-value test surface. Its coins are Monero stagenet test data, not money.
Never send mainnet XMR to it, and never enable crypto-match payouts on it. A stagenet payout path is
a technical test surface, not authorization to offer real-money wagering.
Build and verify one runtime artifact using [RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md), then
transfer only that blob into a new root-owned mode-0700 candidate inbox. Compare the host's SHA-256
with the independently recorded literal digest; the transferred sidecar is not an independent
provenance source.
`ALLOW_MAINNET_PAYOUTS=true` is an engineering interlock only. Attaching redeemable value requires
jurisdiction-specific legal review, age and access controls, responsible-play measures, and an
operational review that this document does not provide. This documentation is not legal advice.
Such Software production uses only the separate, default-closed fleet playbooks for inert blob
staging, two-dump clone validation, wallet candidate preparation/promotion, and one-instance release
activation. Each operation is bound to literal hashes and exact receipts; none infers a release,
dump, wallet, predecessor, or instance. Do not substitute manual extraction under
`/var/www/<instance>/releases`, dependency installation, service commands, or a hand-written
`current` switch. Never make a release directory writable by either application service identity.
Independent MIT deployments leave `OPERATED_PRODUCT_PROFILE` unset and are not constrained by any of
the product decisions above.
---
## Prerequisites
## Step 1 — Reconfigure MAINNET: free + purchased-credit prestige, NO payouts
- A reviewed, tagged commit with the full test suite passing.
- A release artifact built and verified per [RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md).
- A passing `npm run preflight` for each instance's environment file.
- A pre-deploy database backup for each instance (see [LOGS_AND_BACKUP.md](./LOGS_AND_BACKUP.md)).
Install the reviewed source profile `src/.env.mainnet.example` as `/etc/wownerogue/app.env` (mode
0640, `root:wownerogue`), replace every placeholder, and review these money gates. Environment
examples are intentionally not present in the runtime artifact; provision this file through the
hash-pinned fleet/source change, never from the application release directory.
## Operated product profile contracts
When `OPERATED_PRODUCT_PROFILE` is set, `validateOperatedProductProfile()` runs during preflight and
normal startup and rejects any network, identity, or economic-scope drift. Both profiles require:
| Setting | Required value |
|---------|----------------|
| `OPERATOR_NAME` | `Such Software` |
| `OPERATOR_CONTACT_URL` | `mailto:apps@such.software` |
| `OPERATOR_CONTACT_LABEL` | `apps@such.software` |
| `HOSTED_BY` | the profile's exact `https://` public root |
| `LEGAL_POLICY_VERSION`, `TERMS_EFFECTIVE_DATE` | the profile's exact values |
| `PAYMENTS_ENABLED`, `FREE_PLAY_ENABLED`, `PAID_ACKNOWLEDGEMENT_REQUIRED` | `true` |
| `SOLO_ENABLED`, `TAVERN_ENABLED`, `MATCH_ENABLED` | `true` |
| `MATCH_CRYPTO_RACE_ENABLED`, `MATCH_PAYOUTS_ENABLED` | `false` |
| `ALLOW_MAINNET_PAYOUTS` | `false` |
| products | no standalone cosmetic products, and no `grants` key on a credit package |
Per profile:
| Setting | `such-play-wow-prestige` | `such-monerogue-stagenet` |
|---------|--------------------------|---------------------------|
| `CRYPTO_TYPE` / `MONERO_NETWORK` | `WOW` / `mainnet` | `XMR` / `stagenet` |
| `GAME_MODE` | `PAID_CREDITS` | `PAID_SINGLE` |
| `PAYMENT_MODES` | `credits` | `direct` |
| `DIRECT_PAYMENT_ENABLED` | `false` | `true` |
| `CREDITS_ENABLED` | `true` | `false` |
| `PAYOUTS_ENABLED` | `false` | `true` |
| `DIRECT_PAYOUTS_ENABLED` | `false` | `true` |
| `CREDITS_PAYOUTS_ENABLED` | `false` | `false` |
| `DIRECT_PAYOUT_ESCAPE` / `DIRECT_PAYOUT_TREASURE` | not applicable | exactly `2.0` / `3.0` |
The profile also controls what `/api/game-modes` publishes: `selectPublicPaidModeDescriptors()`
emits only the profile's own paid mode, so a Wownero instance never advertises `PAID_SINGLE` and a
Monerogue instance never advertises `PAID_CREDITS`.
## Step 0: Stage an immutable release
Build one runtime artifact, then transfer only that blob into a root-owned mode-0700 candidate
inbox. Compare the host's SHA-256 against an independently recorded digest; the sidecar file that
travelled with the blob is not an independent provenance source.
Blob staging, two-dump clone validation ([CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md)), wallet
candidate preparation and promotion, and per-instance release activation are performed by the
separate, default-closed, hash-pinned fleet automation. Each operation is bound to literal hashes
and exact receipts and infers no release, dump, wallet, predecessor, or instance. Manual extraction
under `/var/www/<instance>/releases`, manual dependency installation, manual service commands, and a
hand-written `current` symlink switch are not supported substitutes. A release directory is never
writable by either application service identity.
The artifact contains only `LICENSE`, `html/**`, and the runtime subset of `src/**`. Environment
examples, tests, documentation, the balance simulator, and root-level operator tooling and service
units are excluded by construction, so none of them can be provisioned out of a release directory.
## Step 1: Configure the mainnet instance
Install `src/.env.mainnet.example` as `/etc/wownerogue/app.env`, mode 0640, owner `root:wownerogue`,
and replace every `CHANGE_ME` value. Provision this file through the hash-pinned fleet or source
change, never from the application release directory.
The decisive money gates for this instance:
```ini
OPERATED_PRODUCT_PROFILE=such-play-wow-prestige
OPERATOR_NAME=Such Software
OPERATOR_CONTACT_URL=mailto:apps@such.software
OPERATOR_CONTACT_LABEL=apps@such.software
FREE_PLAY_ENABLED=true # offer free play as a choice (Pleb board)
GAME_MODE=PAID_CREDITS # exact operated-profile legacy/socket identity
PAYMENT_MODES=credits # only purchased-credit prestige is operated here
DIRECT_PAYMENT_ENABLED=false # per-run payment is outside the operated scope
GAME_MODE=PAID_CREDITS # legacy/socket mode identity for the credits product
PAYMENT_MODES=credits
FREE_PLAY_ENABLED=true # free play is a choice; free runs rank on the Pleb board
CREDITS_ENABLED=true
DIRECT_PAYMENT_ENABLED=false # per-run payment is outside this product scope
PAYOUTS_ENABLED=false # no crypto leaves the house wallet
DIRECT_PAYOUTS_ENABLED=false
CREDITS_PAYOUTS_ENABLED=false
DIRECT_REQUIRES_ADDRESS=false # no payout = no payout address needed
DIRECT_REQUIRES_ADDRESS=false # no payout means no payout address is collected
CREDITS_REQUIRES_ADDRESS=false
TRUST_PROXY=true
TRUST_PROXY_HOPS=1 # exactly one NPM hop
```
Players buy non-redeemable credits for leaderboard/prestige play; free players use the Pleb board.
This operated product offers no winnings, payouts, or cash-out and is not offered or marketed as
gambling. Classification depends on applicable law; this documentation is not legal advice or a
jurisdiction-specific compliance approval.
Amounts on this instance are WOW atomic units (1 WOW = 1e11).
The following are post-activation verification commands, not authorization to restart the live
service while the wallet/preflight and fleet activation gates remain open:
Players buy non-redeemable credits for leaderboard and prestige play; free players use the Pleb
board. The product offers no winnings, payouts, or cash-out.
The Wownero wallet, its key file, password, systemd unit, and RPC credentials are outside the scope
of an application release. Changing any of them requires independently recoverable keyfile, password,
and mnemonic custody, an off-host recovery proof, an isolated rehearsal, exact address and balance
verification, and a separately reviewed maintenance and rollback plan. Root-level wallet templates in
`scripts/deploy/` are operations source, not runtime files and not an instruction to install them.
After activation, verify:
```bash
sudo systemctl restart wownerogue.service
journalctl -u wownerogue.service -n 50 --no-pager # verify: "Payment system: ENABLED", payouts disabled
journalctl -u wownerogue.service -n 50 --no-pager # payment system enabled, payouts disabled
curl -fsS localhost:3000/api/game-modes \
| jq -e '.FREE.enabled == true and .PAID_CREDITS.enabled == true and (has("PAID_SINGLE") | not)'
```
**MAINNET WALLET NO-GO:** this application release does not authorize changing, copying, stopping,
or replacing the Wownero wallet, its key file, password, unit, or RPC authentication. Keep payouts
off. A later wallet change requires independently recoverable keyfile/password and mnemonic custody,
an off-host recovery proof, an isolated rehearsal, exact address/balance verification, and a
separately reviewed maintenance/rollback plan. Root-level wallet templates are operations source,
not files in the runtime artifact and not approval to install them.
## Step 2: Prove the stagenet wallet boundary
---
Wallet work is a custody operation separate from application release work. It runs from a clean,
hash-pinned fleet revision against exactly one selected instance, and preserves every receipt.
## Step 2 — Prove and promote the MONERO STAGENET wallet boundary
Candidate preparation proves that the fixed encrypted stagenet snapshot reopens the exact live
address, has a spend key, and enforces strong Digest credentials, before it publishes inert
dedicated-user material. Promotion stops only the selected legacy wallet, installs only the reviewed
loopback-only unit, requires an unauthenticated `401` plus authenticated address, network, and
balance probes, and rolls back automatically to the exact legacy unit on a failed gate.
Wallet work is a separate custody operation from application release work. Use only the reviewed
`wowngeon_wallet_candidate` and `wowngeon_wallet_promote` roles from a clean, hash-pinned fleet
revision, select exactly `monerogue`, and preserve every generic receipt. Candidate preparation
must prove the fixed encrypted stagenet snapshot reopens the exact live address, has a spend key,
and enforces the application's strong Digest credentials before it publishes inert dedicated-user
material. Promotion must then stop only the selected legacy wallet, install only the reviewed
loopback-only unit, require unauthenticated `401` plus authenticated address/network/balance probes,
and automatically roll back to the exact legacy unit on a failed gate.
Neither operation reads, exports, or proves a mnemonic. A passing keyfile and password proof is not
independent seed custody and not an off-host recovery drill; record those separately until a
human-controlled custody inventory and an isolated off-host restore establish them. That gap is not a
reason to weaken a proof, print a secret, or apply the same workflow to the funded Wownero mainnet
wallet.
The earlier non-mutating design record is
[`README-monero-stagenet-parallel-candidate.md`](../scripts/deploy/README-monero-stagenet-parallel-candidate.md).
It is not an installation procedure and its sample unit is not a promotion artifact.
Neither wallet role reads, exports, or proves a mnemonic. A passing keyfile/password proof is not
independent seed custody or a full off-host recovery drill. Record those separately as unproven
until a human-controlled custody inventory and isolated off-host restore establish them. This gap
does not authorize weakening a proof, printing a secret, or applying either role to the funded
Wownero mainnet wallet. Wownero wallet promotion and the corresponding full mainline application
activation remain blocked pending that separate review. The dated
[schema-neutral production state](PRODUCTION_STATE_2026-07-25.md) does not clear this gate.
## Step 3 — Create the monerogue identity, immutable layout, and DB
## Step 3: Create the monerogue identity, layout, and database
```bash
sudo useradd --system --home /var/www/monerogue --no-create-home --shell /usr/sbin/nologin monerogue
@@ -123,146 +165,106 @@ sudo -u postgres createuser --pwprompt monerogue
sudo -u postgres createdb --owner=monerogue --template=template0 monerogue
```
The service identity owns its database role, not its application files. Do not clone source onto
the production host or recursively `chown` `/var/www/monerogue` to the service user.
The service identity owns its database role, not its application files. Do not clone source onto the
production host and do not recursively `chown` `/var/www/monerogue` to the service user.
## Step 4 — The monerogue `.env`
## Step 4: Configure the monerogue instance
This is the exact no-real-value stagenet test profile. Apply its non-secret policy keys through the
fleet runtime-policy role; do not overwrite or log the protected environment. Keep
`/etc/monerogue/app.env` as `root:monerogue` mode `0640`. Amounts are XMR atomic units
(1 XMR = 1e12):
Install `src/.env.stagenet.example` as `/etc/monerogue/app.env`, mode 0640, owner `root:monerogue`.
Apply its non-secret policy keys through the fleet runtime-policy role; do not overwrite or log the
protected environment. Amounts are XMR atomic units (1 XMR = 1e12).
Host-specific values to set:
```ini
NODE_ENV=production
PORT=3001
TRUST_PROXY=true
TRUST_PROXY_HOPS=1
OPERATED_PRODUCT_PROFILE=such-monerogue-stagenet
OPERATOR_NAME=Such Software
OPERATOR_CONTACT_URL=mailto:apps@such.software
OPERATOR_CONTACT_LABEL=apps@such.software
LEGAL_POLICY_VERSION=2026-07-23-v2
TERMS_EFFECTIVE_DATE=2026-07-23
CRYPTO_TYPE=XMR
MONERO_NETWORK=stagenet
HOSTED_BY=https://monerogue.app
DIFFICULTY_PRESET=casino
# NO REAL VALUE: test coins only. Never send mainnet XMR.
# --- payments + free play ---
PAYMENTS_ENABLED=true
GAME_MODE=PAID_SINGLE
PAYMENT_MODES=direct
FREE_PLAY_ENABLED=true # free and direct solo boards available
# --- direct (pay per game) WITH payouts (fake stagenet money) ---
DIRECT_PAYMENT_ENABLED=true
DIRECT_GAME_PRICE=10000000000 # 0.01 XMR
DIRECT_REQUIRES_ADDRESS=true
DIRECT_PAYOUTS_ENABLED=true
DIRECT_PAYOUT_ESCAPE=2.0
DIRECT_PAYOUT_TREASURE=3.0
# --- purchased credits are outside the operated Monerogue profile ---
CREDITS_ENABLED=false
CREDITS_PER_GAME=1
CREDITS_REQUIRES_ADDRESS=false
CREDITS_PAYOUTS_ENABLED=false
# Inert compatibility values; no credit purchase or credit payout path may expose them.
CREDITS_PAYOUT_BASE=10000000000
CREDITS_PAYOUT_ESCAPE=2.0
CREDITS_PAYOUT_TREASURE=3.0
CREDITS_PACKAGES=[{"id":"small","credits":10,"price":"90000000000","bonus":0},{"id":"medium","credits":20,"price":"170000000000","bonus":2}]
ALLOW_MIXED_MODE=false
PREFER_CREDITS_FIRST=false
# --- payouts --- (verify the unlocked stagenet bankroll before every rollout)
PAYOUTS_ENABLED=true
ALLOW_MAINNET_PAYOUTS=false
PAYOUT_MIN_AMOUNT=1000000000 # 0.001 XMR
PAYOUT_MAX_PER_GAME=50000000000 # 0.05 XMR safety cap (real max is 3x*0.01 = 0.03)
PAYOUT_BATCH_INTERVAL=300
PAYOUT_MAX_RETRIES=3
BALANCE_WARN=100000000000 # warn below 0.10 XMR unlocked
BALANCE_CRITICAL=50000000000 # stop accepting new payout liability below 0.05 XMR
# Tip: lower DIRECT_GAME_PRICE to 0.001 XMR (1000000000) for many more demo plays per the
# small bankroll; refill from a stagenet faucet as needed.
# --- wallet RPC (monero stagenet) ---
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:38083
# --- daemon RPC (monerod stagenet) ---
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:38081
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:38083 # monero-wallet-rpc, stagenet
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:38081 # monerod, stagenet
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:38081
RPC_POLL_INTERVAL=2000
# --- database ---
DB_HOST=localhost
DB_PORT=5432
DB_NAME=monerogue
DB_USER=monerogue
DB_PASSWORD=CHANGE_ME
# --- admin ---
ADMIN_API_KEY=GENERATE_WITH_openssl_rand_hex_32
# --- smirk: disable for monero stagenet (Smirk doesn't support stagenet) ---
SMIRK_ENABLED=false
# --- PvP financial boundary: free/prestige rulesets may evolve, crypto payouts may not ---
MATCH_CRYPTO_RACE_ENABLED=false
MATCH_PAYOUTS_ENABLED=false
SMIRK_ENABLED=false # Smirk does not support stagenet
```
The application unit separately requires `/etc/monerogue/wallet-rpc.env`, owned
`root:monerogue` mode `0640`, containing only `WALLET_RPC_USER` and `WALLET_RPC_PASSWORD`. Provision
it only through the reviewed wallet-candidate workflow. Never place either
password in a command argument, unit `ExecStart`, shell history, or this runbook.
Economy values, all in stagenet test coins with no real value:
Production startup proves that wallet RPC actually enforces Digest authentication: an intentional
unauthenticated, read-only probe must receive a usable `401` Digest challenge before the
authenticated identity probe is accepted. Merely setting application credentials is insufficient;
a wallet RPC that still accepts the unauthenticated request makes preflight/readiness fail closed.
```ini
DIRECT_GAME_PRICE=10000000000 # 0.01 XMR per run
DIRECT_PAYOUT_ESCAPE=2.0
DIRECT_PAYOUT_TREASURE=3.0
PAYOUT_MIN_AMOUNT=1000000000 # 0.001 XMR
PAYOUT_MAX_PER_GAME=50000000000 # 0.05 XMR cap; the reachable maximum is 3 x 0.01 = 0.03
PAYOUT_BATCH_INTERVAL=300
PAYOUT_MAX_RETRIES=3
BALANCE_WARN=100000000000 # warn below 0.10 XMR unlocked
BALANCE_CRITICAL=50000000000 # stop accepting new payout liability below 0.05 XMR
DIFFICULTY_PRESET=casino
```
Verify the unlocked stagenet bankroll before every rollout. Lowering `DIRECT_GAME_PRICE` to
`1000000000` (0.001 XMR) yields many more demo runs per faucet refill.
`CREDITS_PAYOUT_BASE`, `CREDITS_PAYOUT_ESCAPE`, `CREDITS_PAYOUT_TREASURE`, and `CREDITS_PACKAGES`
remain in the file as inert compatibility values. `CREDITS_ENABLED=false` and
`CREDITS_PAYOUTS_ENABLED=false` keep every credit purchase and credit payout path fail-closed, so
nothing exposes them.
The stagenet profile leaves `FINANCIAL_EVENT_SINK_URL` and `FINANCIAL_EVENT_SINK_TOKEN` blank.
Startup rejects a configured financial-event sink on a test network, because no-real-value events
must never reach a real accounting sink.
The service unit additionally requires `/etc/monerogue/wallet-rpc.env`, owner `root:monerogue`, mode
0640, containing only `WALLET_RPC_USER` and `WALLET_RPC_PASSWORD`. Provision it only through the
wallet-candidate workflow. Neither password belongs in a command argument, a unit `ExecStart`, shell
history, or this document.
Startup proves that wallet RPC actually enforces Digest authentication: a deliberate unauthenticated
`get_version` probe must receive a usable `401` Digest challenge before the authenticated identity
probe is accepted. Setting application credentials is not sufficient; a wallet RPC that still answers
the unauthenticated request fails readiness closed with `WALLET_RPC_AUTH_NOT_ENFORCED`.
```bash
sudo chmod 0640 /etc/monerogue/app.env
sudo chown root:monerogue /etc/monerogue/app.env
cd /var/www/monerogue/releases/<reviewed-release-id>/src
cd /var/www/monerogue/releases/<release-id>/src
sudo -u monerogue bash -c 'set -a; . /etc/monerogue/app.env; . /etc/monerogue/wallet-rpc.env; set +a; npm run preflight'
```
## Step 5 — Install + start the monerogue instance
## Step 5: Activate the instance and close the network
The runtime artifact contains no service units. Promote the wallet and activate `monerogue` only
through the separately reviewed, hash-pinned `~/src/such-fleet` roles after the recovery,
clone-validation, protected-environment, wallet, drain, and financial-audit gates pass. Do not copy
a unit from the application release.
The runtime artifact contains no service units. Wallet promotion and instance activation run through
the hash-pinned fleet roles after the recovery, clone-validation, protected-environment, wallet,
drain, and financial-audit gates pass. Do not copy a unit out of an application release.
Install the hash-pinned fleet copies of `wowngeon-firewall.sh` and
`wowngeon-firewall.service`, then set the fixed proxy source as `NPM_SOURCE_IPV4=<address>` in
`/etc/wowngeon/firewall.env`. Verify direct access to ports 3000/3001 is rejected while both
domains, websocket upgrades, and local readiness probes still work.
Install the hash-pinned copies of `scripts/deploy/wowngeon-firewall.sh` (mode 0755 at
`/usr/local/sbin/wowngeon-firewall`) and `scripts/deploy/wowngeon-firewall.service`, then set the
fixed proxy address as `NPM_SOURCE_IPV4=<address>` in `/etc/wowngeon/firewall.env`. The script is
idempotent: it rejects any non-loopback traffic to ports 3000 and 3001 that does not come from that
address, and rejects all non-loopback traffic to the wallet and daemon JSON-RPC ports 34568, 34570,
38081, and 38083. Peer-to-peer ports are deliberately untouched.
## Step 6 — Verify end to end
Confirm afterwards that direct access to ports 3000 and 3001 is refused while both domains, their
websocket upgrades, and local readiness probes still work.
- `monerogue.app` loads; `play.wowne.ro` still healthy.
- Each public modes document contains only its operated paid solo product:
Wownero has `PAID_CREDITS` and no `PAID_SINGLE`; Monerogue has `PAID_SINGLE` and no
`PAID_CREDITS`.
- After wallet promotion and the separately approved bounded canary: buy credits / pay entry with a
stagenet wallet → game starts → qualifying solo outcome → 2×/3× test-coin payout arrives.
- After the corresponding gates clear, both boards populate: free game → Pleb, paid game → Hall of
Champions, on each instance.
- Mainnet, only after its separate recovery gate and release: confirm purchased-credit play never
creates a payout or cash-out path.
## Step 6: Verify end to end
---
- `monerogue.app` loads and `play.wowne.ro` stays healthy.
- Each public modes document contains only its own operated paid product: Wownero exposes
`PAID_CREDITS` and no `PAID_SINGLE`; Monerogue exposes `PAID_SINGLE` and no `PAID_CREDITS`.
- On Monerogue, run the bounded canary described in
[STAGENET_FINANCIAL_CANARY.md](./STAGENET_FINANCIAL_CANARY.md): pay entry from a stagenet wallet,
the game starts, a qualifying solo outcome pays 2x or 3x in test coins.
- Both boards populate on each instance: a free game ranks on Pleb, a paid game on the Hall of
Champions.
- On mainnet, confirm that purchased-credit play creates no payout or cash-out path.
## Rollback
- Stop only the affected instance, atomically repoint `/var/www/<instance>/current` to the previous
immutable release, and restart it. Keep the pre-deploy database backup for an operator-reviewed
restore if a data rollback is required.
- Never overwrite either legacy dirty checkout; preserve it until the new release has passed its soak.
- Stop only the affected instance, atomically repoint `/var/www/<instance>/current` at the previous
immutable release, and restart it.
- Keep the pre-deploy database backup for an operator-reviewed restore if a data rollback is needed.
The backup timer prunes dumps older than `BACKUP_RETENTION_DAYS` (default 14), so preserve a dump
you may still need outside the pruned directory.
- Preserve the previous release directory until the new one has passed its soak.
+45 -27
View File
@@ -1,18 +1,21 @@
# Historical financial constraint release gate
Several migrations add `CHECK ... NOT VALID`. PostgreSQL enforces those constraints for new and
changed rows, but it does not scan rows that existed before the constraint was added. A migration
even a current migration ledger therefore does not, by itself, prove that all historical financial rows obey
Several migrations add `CHECK ... NOT VALID`. PostgreSQL enforces those constraints for every new and
changed row, but it does not scan rows that already existed when the constraint was added. A complete
`schema_migrations` ledger therefore does not, by itself, prove that historical financial rows obey
the current constraints.
Two repository-owned gates close that evidence gap without changing a live database.
Two repository-owned gates close that evidence gap without changing a live database. Both cover the
same 18 constraints across `payments`, `payouts`, `games`, `matches`, `match_queue_entries`,
`race_entry_lots`, and `payment_refunds`.
## 1. Read-only historical audit
`scripts/deploy/financial-constraint-audit.sql` checks the exact connected database name, verifies
the complete 18-constraint catalog inventory, and reports a named count for every historical
predicate. It uses one repeatable-read, read-only transaction and rolls it back. Connection and
credential handling remain operator-specific; do not put a database password in command history.
the complete 18-constraint catalog inventory, and reports a named row count for every historical
predicate. It runs in one repeatable-read, read-only transaction, takes no explicit table lock, and
rolls back. Connection and credential handling stay operator-specific; keep database passwords out of
command history.
```bash
psql -X -v ON_ERROR_STOP=1 \
@@ -20,27 +23,35 @@ psql -X -v ON_ERROR_STOP=1 \
--file=scripts/deploy/financial-constraint-audit.sql
```
Exit status `0` means all required constraints exist and every old row satisfies them. Any missing
constraint or nonzero category exits `3`. Reconcile violations with a separately reviewed,
backup-backed repair; this audit intentionally has no repair mode.
Exit codes:
This check may be run read-only against production and should also run against every restored
release candidate database. It reports how many constraints remain catalog-marked unvalidated;
that count is informational because the second gate proves PostgreSQL's validator itself.
| Code | Meaning |
| --- | --- |
| `0` | Every required constraint exists and every historical row satisfies it. |
| `2` | `expected_database` was not supplied, or the connected database does not match it exactly. |
| `3` | A required constraint is missing, or at least one predicate has a nonzero violation count. |
The violation predicates mirror PostgreSQL `CHECK` semantics: only `FALSE` counts as a violation,
since `CHECK` accepts `NULL`. Reconcile any violation with a separately reviewed, backup-backed
repair. The audit has no repair mode by design.
This check is safe against production and should also run against every restored release candidate
database. It reports how many of the constraints are still catalog-marked unvalidated; that count is
informational, because the second gate exercises PostgreSQL's own validator.
## 2. Explicit validation on a disposable restore
`scripts/deploy/financial-constraint-validate-restore.sql` includes the read-only audit, then issues
an explicit `ALTER TABLE ... VALIDATE CONSTRAINT` for all 18 constraints inside a transaction. It
checks `pg_constraint.convalidated` and rolls the entire transaction back, so no catalog flag is
durably changed.
confirms `pg_constraint.convalidated` for each one and rolls the whole transaction back, so no
catalog flag changes durably.
`VALIDATE CONSTRAINT` still scans tables, takes locks, and transiently updates PostgreSQL catalogs.
For that reason this script fails before `BEGIN` unless all three conditions hold:
The script therefore refuses to reach `BEGIN` unless all three conditions hold:
- the connected name exactly equals `expected_database`;
- the database name contains `restore` or `scratch`;
- the exact disposable-restore confirmation is supplied.
- the connected database name exactly equals `expected_database`;
- that name contains `restore` or `scratch`;
- the exact disposable-restore confirmation string is supplied.
Run it only against a freshly restored, isolated database with no application traffic:
@@ -51,19 +62,26 @@ psql -X -v ON_ERROR_STOP=1 \
--file=scripts/deploy/financial-constraint-validate-restore.sql
```
A pass proves both the explicit historical predicates and PostgreSQL's native validation path on
that restored data. It does not mark live constraints validated. A later durable live
`VALIDATE CONSTRAINT` operation requires its own reviewed maintenance plan, backup/restore proof,
lock budget, and rollback decision; do not fold that mutation into application startup.
Both scripts set `statement_timeout` to 10 minutes and `lock_timeout` to 5 seconds for the duration
of their transaction.
A pass proves both the explicit historical predicates and PostgreSQL's native validation path on that
restored data. It does not mark live constraints validated. Making a live `VALIDATE CONSTRAINT`
durable is a separate operation that needs its own reviewed maintenance plan, backup and restore
proof, lock budget, and rollback decision. Do not fold that mutation into application startup.
## Static coverage
The focused test inventories every migration occurrence of `NOT VALID` and fails if either gate
omits a constraint:
A focused test inventories every migration occurrence of `NOT VALID` and fails if either gate omits a
constraint or if the inventory size drifts from 18:
```bash
npm --prefix src test -- --runTestsByPath ../test/financialConstraintValidation.test.js
```
Adding another `NOT VALID` constraint requires adding its exact historical predicate, its explicit
restore validation statement, and its table/name entry to both gates.
The test also asserts that the audit script contains no `ALTER TABLE` and no write statement, so the
read-only gate cannot silently gain a mutation.
Adding another `NOT VALID` constraint requires three matching updates: its exact historical predicate
and inventory entry in the audit script, its `VALIDATE CONSTRAINT` statement and inventory entry in
the restore script, and its table and name in the test's constraint list.
+137 -27
View File
@@ -1,26 +1,106 @@
# Financial-event export
# Financial event export
Migrations 041 and 042 create a durable outbox for confirmed customer payments and final refund
states. Migration 042 stores an immutable source snapshot before delivery; the exporter never
reconstructs an event from later-mutated payment rows. Payloads contain transaction classification,
atomic amount, asset/network, catalog/provider identifiers, and receipt count, but no player ID,
wallet address, session token, IP address, email address, or authentication material.
Migrations `041_financial_event_outbox.sql` and `042_immutable_financial_event_snapshots.sql` create
`financial_event_outbox`, a durable outbox for confirmed customer payments and final refund states.
Database triggers append an immutable source snapshot inside the payment or refund transaction, so a
slow or unreachable accounting receiver can never hold a money transaction open.
`src/services/financialEventExporter.js` performs delivery asynchronously and never reconstructs an
event from later-mutated payment rows.
Delivery is **at least once**. Every request sets `Idempotency-Key` to the immutable financial event
ID. The receiving system must durably deduplicate that key before acknowledging with a 2xx response.
A timeout or lost acknowledgement can otherwise produce a repeat request. The application marks a
row delivered only after a 2xx response; bounded retries use a durable lease and exponential delay,
then move an exhausted row to `dead_letter` for operator review.
## Event sources
The public health response exposes only aggregate exporter state: enabled/running flags, backlog,
dead-letter and ignored counts, and cumulative delivery counters. It exposes no sink URL,
token, event ID, error text, payload, or source identity. Accounting export is informational and does
not independently make gameplay readiness fail, so production monitoring must alert on backlog age,
dead letters, and the exporter process logs/metrics needed to detect stale delivery.
| Event kind | Trigger source | Fires when |
| --- | --- | --- |
| `payment.confirmed` | `payments` | `status` becomes `confirmed` and `received_amount > 0` |
| `payment.refund` | `payment_refunds` | `status` reaches `recorded` (bookkeeping reversal) or `completed` (wallet refund) |
## Configuration contract
Refunds in `requested`, `processing`, or `needs_review` are never accounting events. Confirmed
payments with `received_amount = 0` lack economic evidence and are never turned into revenue.
Uniqueness on `(event_kind, source_type, source_id)` and on `event_id` means one source produces at
most one event; the foreign key to `payments` is `ON DELETE RESTRICT`.
Configure both of these only in the protected Wownero mainnet runtime environment:
## Source snapshot
`payload_snapshot` uses schema `financial-event-source/v1`. A CHECK constraint restricts it to
exactly this key set: `schema`, `event_id`, `event_kind`, `source_type`, `source_id`,
`aggregate_id`, `occurred_at`, `atomic_amount`, `payment_type`, `product_id`, `provider_id`,
`receipt_count`, `refund_status`. The constraint also pins `event_id`, `event_kind`, `source_type`,
`source_id`, and `aggregate_id` to the matching columns.
The snapshot carries no player ID, wallet address, invoice destination, transaction hash, provider
invoice ID, session token, IP address, email address, or authentication material.
## Delivery document
On the first delivery attempt the worker builds `delivery_payload` from the snapshot plus the
deployment's chain profile (`src/chain/chainProfile.js`) and writes it once. Schema is
`financial-event/v1`:
- `id`: the immutable event ID, also used as the idempotency key.
- `occurred_at`: ISO-8601 timestamp derived from the snapshot.
- `producer` / `product`: `wowngeon`.
- `activity`: `customer-payment`, `customer-refund` (completed wallet refund), or
`customer-payment-reversal` (recorded bookkeeping reversal).
- `account_refs` and each leg's `account_ref`: `FINANCIAL_EVENT_ACCOUNT_REF`.
- `provenance`: one entry of kind `wowngeon-payment` or `wowngeon-refund` referencing
`payment-<id>` or `payment-refund-<id>`.
- `legs`: one leg with the chain profile's `asset` symbol, decimal `quantity`, `direction` `in` for
payments and `out` for refunds, and a `network` identifier.
- `attributes`: `valuation_required`, `atomic_amount`, `atomic_decimals`, `source_type`,
`source_id`, `aggregate_id`, plus `refund_status` for refunds or
`payment_type` / `product_id` / `provider_id` / `receipt_count` for payments.
The leg's `usd_value` is `"0"` with empty `price_source` and `price_confidence`, and `memo` plus
`attributes.valuation_required` state that the receiving system must apply USD valuation before
confirming the entry. The exporter deliberately performs no pricing.
The `network` identifier is `wownero:mainnet` when `CRYPTO_TYPE` resolves to the WOW profile, and
`monero:<MONERO_NETWORK>` otherwise. An unrecognised `CRYPTO_TYPE` resolves to the XMR-shaped
default profile (12 decimals, 120000 ms mean block time).
Once written, the delivery document is immutable and every retry reuses it byte for byte.
## Delivery contract
Delivery is **at least once**. Each attempt is a `POST` with a bearer `authorization` header,
`content-type: application/json`, `idempotency-key` set to the immutable event ID, manual redirect
handling, and a 10-second timeout. The receiving system must durably deduplicate that key and
durably record the event before answering 2xx. A timeout or lost acknowledgement otherwise produces
a repeat request. The application marks a row `delivered` only after a 2xx response.
## Row lifecycle
`pending` → `in_flight` → `delivered`, with `dead_letter` and `ignored` as the other terminal
states.
- A run first moves rows whose `attempts` already reached `FINANCIAL_EVENT_MAX_ATTEMPTS` to
`dead_letter`.
- Claiming selects the oldest due row with `FOR UPDATE SKIP LOCKED`, increments `attempts`, and
takes a five-minute lease. An expired lease makes the row claimable again, so a crashed worker
never strands an event.
- A failed attempt returns the row to `pending` with backoff of 30 seconds doubled per attempt and
capped at one hour, recording the sanitised error in `last_error`.
- An attempt that exhausts `FINANCIAL_EVENT_MAX_ATTEMPTS` moves the row to `dead_letter` for
operator review.
- Each run processes at most `FINANCIAL_EVENT_BATCH_SIZE` rows.
### Immutability
A `BEFORE UPDATE` trigger rejects any change to `event_kind`, `aggregate_id`, `source_type`,
`source_id`, `event_id`, or `payload_snapshot`. `delivery_payload` has exactly one legal transition,
`NULL` to its final value, and only while the row is `in_flight`. A row cannot reach `delivered`
without a delivery document.
## Configuration
| Variable | Default | Notes |
| --- | --- | --- |
| `FINANCIAL_EVENT_SINK_URL` | unset | Enables export together with the token |
| `FINANCIAL_EVENT_SINK_TOKEN` | unset | Bearer secret, at least 32 characters |
| `FINANCIAL_EVENT_ACCOUNT_REF` | `wowngeon:receipts` | Non-PII account identifier |
| `FINANCIAL_EVENT_POLL_MS` | `60000` | Clamped to 5000-3600000 |
| `FINANCIAL_EVENT_BATCH_SIZE` | `20` | Clamped to 1-100 |
| `FINANCIAL_EVENT_MAX_ATTEMPTS` | `8` | Clamped to 1-100 |
```dotenv
FINANCIAL_EVENT_SINK_URL=https://ledger.operator.example/v1/events
@@ -31,13 +111,43 @@ FINANCIAL_EVENT_BATCH_SIZE=20
FINANCIAL_EVENT_MAX_ATTEMPTS=8
```
Production requires HTTPS, forbids URL-embedded credentials, and requires a strong token. The account
reference is a non-PII identifier. URL and token must be set together. Never place the token in Git,
Ansible inventory, an invocation, a receipt, a log, or a public health check.
The full environment template is `src/.env.example`; the mainnet and stagenet profiles are
`src/.env.mainnet.example` and `src/.env.stagenet.example`.
Monero stagenet deliberately sends nothing to an accounting sink. Its pending, leased, or dead-letter
test rows are moved to `ignored` with reason `non_mainnet_network`; startup rejects sink configuration
on any non-mainnet network. This keeps **NO REAL VALUE** test activity out of real books.
Startup validation (`src/config/environmentValidator.js`) and the exporter constructor both enforce:
- URL and token are set together or not at all.
- The URL parses, uses `http:` or `https:`, and embeds no credentials. `https:` is required when
`NODE_ENV=production`.
- The token is at least 32 characters, contains at least 8 distinct characters, and matches no
placeholder pattern such as `change_me`, `example`, `password`, or `secret`.
- The account reference matches `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$`.
- Sink configuration is rejected on any non-mainnet `MONERO_NETWORK`.
Set the URL and token only in the protected mainnet runtime environment. Never place the token in
Git, Ansible inventory, an invocation, a receipt, a log, or a public health check.
## Non-mainnet networks
When `MONERO_NETWORK` is not `mainnet`, export is disallowed and each run moves every `pending`,
`in_flight`, or `dead_letter` row to `ignored` with reason `non_mainnet_network`. This keeps
no-value test activity out of real books. Migration 042 separately marks legacy rows whose
historical amount is unusable as `ignored` with reason `malformed_legacy_snapshot`, so they
terminate instead of retrying forever.
## Health reporting
The public `/health` response includes a `financialEvents` object: the delivery contract string,
`enabled`, `exportAllowed`, `running`, the `backlog`, `deadLetters`, and `ignoredRows` gauges, and
the cumulative `delivered`, `deferred`, `deadLettered`, and `ignored` counters. It exposes no sink
URL, token, account reference, event ID, error text, payload, source identity, or timestamp.
Accounting export is informational and does not make gameplay readiness fail, so production
monitoring must alert on backlog age, dead letters, and the exporter's process logs and metrics in
order to detect stale delivery.
The poll loop runs even with no sink configured, so the backlog gauge stays truthful and non-mainnet
deployments keep suppressing newly appended rows.
## Operations
@@ -50,6 +160,6 @@ Before enabling mainnet export, prove in a disposable environment that the sink:
5. supports alerting and reconciliation for a deliberately generated dead letter.
Keep the outbox in database backups. Do not delete or rewrite a dead-letter row to make monitoring
green; reconcile it against the receiving ledger, preserve the evidence, and use a separately reviewed
repair procedure. An absent sink is permitted, but confirmed mainnet events then remain pending and
must be included in the production accounting handoff.
green: reconcile it against the receiving ledger, preserve the evidence, and use a separately
reviewed repair procedure. Running without a sink is permitted, but confirmed mainnet events then
accumulate as pending rows and must be reconciled by another route.
+81 -28
View File
@@ -1,42 +1,95 @@
# Financial reconciliation
The admin dashboard has separate **Payouts** and **Refunds** views. Start with the
**Needs Attention** filter and compare the row, application logs, and wallet history.
The overview and each list show `processing` and `needs_review` counts; these are
liabilities, not failed transfers that can safely be resent.
Payouts and refunds move real funds, so any row that is not in a terminal state is treated
as a liability with an unknown on-chain outcome rather than a failed transfer that can be
resent. This document describes the states, the retry rules that follow from them, and the
manual reconciliation procedure.
## State rules
## Where to look
- A payout in `processing` or `needs_review` may already have broadcast. Never use the
retry action for it.
- The payout retry action is available only for a hashless `failed` or
`permanently_failed` row. Use it only after proving the recorded error happened before
broadcast and confirming that wallet history contains no matching transfer. The UI
sends the API's required explicit confirmation.
- A refund in `requested` has not yet been claimed by the refund worker. A refund in
`processing` may have broadcast. A refund in `needs_review` is quarantined. There is no
dashboard resend action for any refund state.
- `recorded` means entitlements/payment state was reversed without an on-chain transfer;
`completed` requires transaction-hash evidence.
The admin dashboard (`html/admin.html`) has separate **Payouts** and **Refunds** tabs. Each
has a status filter whose **Needs Attention** option selects exactly the non-terminal states:
- Payouts: `pending`, `processing`, `failed`, `needs_review`, `permanently_failed`.
- Refunds: `requested`, `processing`, `needs_review`.
The overview panel and each list also report per-status counts. Compare the row against the
service logs and the wallet's outgoing-transfer history before taking any action.
## Payout states
| State | Meaning |
| --- | --- |
| `pending` | Queued for the batch payout worker in `gameModeManager`. That worker owns these rows exclusively. |
| `processing` | Claimed by a worker. The wallet call may already have broadcast. |
| `completed` | Settled, with a transaction hash recorded. |
| `failed` | The wallet call reported an error and the row is below the retry limit. |
| `permanently_failed` | Retries reached `PAYOUT_MAX_RETRIES` (default 3). |
| `needs_review` | Quarantined for manual reconciliation. Never retried automatically. |
| `batched` | Legacy status from an earlier batching scheme. Current code does not write it; it remains accepted by the status constraint and is counted in dashboard totals. |
`PayoutRetryService` retries only `failed` rows below the retry limit, claiming each with
`FOR UPDATE SKIP LOCKED` so the batcher and the retry worker cannot both send the same row.
It also sweeps `processing` rows that have not advanced within its stale window and moves
them to `needs_review` with the reason recorded in `last_error`. The stale window defaults to
`max(PAYOUT_RETRY_INTERVAL_MS * 3, 15 minutes)`.
## Refund states
Refunds have their own durable outbox table, `payment_refunds`. Its state shape is enforced
by a database CHECK constraint, so a state and its timestamps or evidence cannot disagree.
| State | Meaning |
| --- | --- |
| `recorded` | Entitlements and payment state were reversed with no on-chain transfer requested. Terminal. |
| `requested` | Queued for a transfer. Not yet claimed. |
| `processing` | Claimed. The wallet call may already have broadcast. |
| `completed` | Settled. The constraint requires a non-empty `tx_hash`. |
| `needs_review` | Quarantined. The constraint requires a non-empty `error_message`. |
Only `requested` rows may be claimed. No code path moves `processing` back to `requested`,
so a crash or a lost wallet response can never produce an automatic second transfer.
## Retry rules
- A payout in `processing`, `needs_review`, or any state carrying a transaction hash must
never be retried. Absence of a matching transfer in wallet history is not proof that no
broadcast occurred.
- `POST /api/admin/payouts/:id/retry` accepts only a hashless `failed` or
`permanently_failed` row and requires an explicit `confirm` (query parameter or JSON body);
anything else returns 409 or 400. The dashboard exposes the Retry button only for those
rows and sends the confirmation after a local prompt. The update predicate re-checks every
safety condition, so a concurrent status change cannot be overwritten.
- Use the retry action only after proving the recorded error happened before broadcast and
confirming that wallet history contains no matching transfer.
- There is no resend action for any refund state, in the dashboard or the API.
## Reconciliation checklist
1. Preserve the database row, service logs, wallet files, and current backups before
changing anything.
2. Match currency/network, destination, exact atomic amount, creation/processing time,
transaction hash (if present), and wallet outgoing-transfer history.
2. Match currency and network, destination, exact atomic amount, creation and processing
times, transaction hash if present, and wallet outgoing-transfer history.
3. If a matching wallet transfer exists, record its hash and confirmation evidence in an
audited remediation. Do not create a replacement transfer.
4. If no transfer appears, treat that as inconclusive until the wallet is synchronized and
the relevant time window and mempool are checked. Escalate ambiguous cases instead of
retrying.
5. Re-run the read-only financial audit after any reviewed database remediation and retain
the before/after evidence with the incident record.
the relevant time window and mempool have been checked. Escalate ambiguous cases instead
of retrying.
5. Re-run the read-only audit, `scripts/deploy/financial-audit.sql`, after any reviewed
database remediation and retain the before and after evidence with the incident record.
Periodic alerts use the same aggregate states. `FINANCIAL_REVIEW_STALE_MS` controls when a
payout `processing` row or refund `requested`/`processing` row is considered stale; the
default is 15 minutes. Alerts are read-only and never move or retry a transfer.
## Alerts
There is intentionally no generic "mark resolved" endpoint. A reviewed state transition
needs a case-specific, audited remediation because the correct result depends on wallet
evidence and whether value was actually transferred.
`AlertService` runs periodic checks over the same aggregate states and sends two financial
alerts: one for any payout or refund in `needs_review`, and one for payout `processing` rows
or refund `requested`/`processing` rows that have not moved within `FINANCIAL_REVIEW_STALE_MS`
(default 900000, that is 15 minutes). Both resolve automatically once their count returns to
zero. Alerts are read-only: they never move, retry, or cancel a transfer.
Environment variables referenced here are documented in `src/.env.example`.
## No generic resolve endpoint
There is deliberately no "mark resolved" endpoint. A reviewed state transition needs a
case-specific, audited remediation because the correct result depends on wallet evidence and
on whether value was actually transferred.
+238 -87
View File
@@ -1,54 +1,66 @@
# Smirk Wallet Integration
How Wownerogue integrates with the [Smirk](https://smirk.cash) browser extension for wallet-based authentication.
How Wowngeon integrates with the [Smirk](https://smirk.cash) browser extension for wallet-based
sign-in, payments, and signed chat.
Smirk injects a frozen `window.smirk` object on every page, in the style of `window.ethereum`. Sites
use it to request public keys, request signatures, read wallet addresses, and prompt an in-extension
payment. Wowngeon uses all four.
---
## Overview
## Enablement
Smirk injects a `window.smirk` API (similar to MetaMask's `window.ethereum`) that websites can use to:
Smirk is opt-in and mainnet-only:
1. Request wallet connection (get public keys)
2. Request message signatures (for authentication)
3. Get wallet addresses (for payouts)
4. Request payments (send WOW directly from extension)
Wownerogue uses the WOW (Wownero) key for authentication via a challenge-response signature flow, and `requestPayment()` for in-extension payment prompts.
- `SMIRK_ENABLED=true` must be set (see `src/.env.example`).
- `src/auth/smirkPolicy.js` forces the feature off on any test network (stagenet/testnet),
regardless of `SMIRK_ENABLED`.
- The auth routes in `src/routes/auth.js` are only mounted when the policy returns true
(`src/index.js`).
- The server advertises the resolved state to clients as `smirkEnabled` on the `game_mode_info`
socket event. The client fails closed: `SmirkAuth.init()` returns early unless
`SocketHandlers._smirkEnabled === true`.
---
## Authentication Flow
## Authentication
Two proof shapes are accepted. The client prefers NIP-98 and falls back to the legacy Ed25519 path
when the wallet does not advertise nostr signing, or when nostr signing fails for any reason other
than an explicit user cancellation.
Every auth call is session-gated. The caller must send the browser's session bearer token in the
`X-Session-Token` header (`users.anon_token`, stored client-side as `wownerogue_token`). The token
proves ownership of the socket, which by itself is public: Socket.IO ids are broadcast in Tavern and
match state, so a socket id alone can never authorize attaching a wallet key.
```
Client Server Extension
| | |
|-- POST /challenge --------->| |
|<-------- { challenge } -----| |
| | |
|-- smirk.connect() ---------------------------------->|
|<-------------- { publicKeys } -----------------------|
| | |
|-- smirk.signMessage(challenge) --------------------->|
|<-------------- { signatures[] } ---------------------|
| | |
|-- POST /verify ------------>| |
| { challenge, publicKey, | |
| signature, socketId } | |
|<------ { linked: true } ----| |
| | |
|-- smirk.getAddresses() ------------------------------>|
|<-------------- { wow: "Wo..." } ---------------------|
| | |
|-- socket: address:update -->| |
Client Server Extension
| | |
|-- POST /challenge ----------->| |
| X-Session-Token, socketId | |
|<------- { challenge } --------| |
| | |
|-- smirk.signNostrEvent(evt) ------------------------------->|
|<---------------- signed kind:27235 event -------------------|
| | |
|-- POST /verify -------------->| |
| X-Session-Token, | |
| { socketId, event } | |
|<---- { linked: true, ... } ---| |
| | |
|-- smirk.getAddresses() ------------------------------------>|
|<---------------- { wow: "Wo..." } -------------------------|
| | |
|-- socket: address:update ---->| |
```
---
## API Endpoints
### POST `/api/auth/smirk/challenge`
Generate a challenge for signature verification.
Issue a single-use nonce.
**Headers:** `X-Session-Token: <session token>`
**Request:**
```json
@@ -60,13 +72,27 @@ Generate a challenge for signature verification.
{ "challenge": "<64-char hex string>", "expiresIn": 300 }
```
The challenge is a random 32-byte hex string stored in the database with a 5-minute TTL.
The challenge is 32 random bytes, hex encoded, inserted into `smirk_challenges` bound to both the
socket id and the stable `users.id` resolved from the session token. It expires after five minutes.
Each call also prunes expired rows and consumed rows older than one hour.
Rate limit: 10 requests per minute per IP.
### POST `/api/auth/smirk/verify`
Verify a wallet signature and link it to the user session.
Verify a wallet proof and link the wallet to the account.
**Request:**
**Headers:** `X-Session-Token: <session token>`
**Request (NIP-98, preferred):**
```json
{
"socketId": "<socket.io id>",
"event": { "kind": 27235, "created_at": 0, "pubkey": "", "id": "", "sig": "", "content": "", "tags": [] }
}
```
**Request (legacy Ed25519):**
```json
{
"socketId": "<socket.io id>",
@@ -76,57 +102,127 @@ Verify a wallet signature and link it to the user session.
}
```
**Response (success):**
The route selects the NIP-98 path when `event` is a non-array object, and the legacy path otherwise.
**Response (wallet linked to the current account):**
```json
{ "success": true, "linked": true, "message": "Smirk wallet linked successfully" }
```
**Errors:**
- `400` - Missing fields, invalid/expired challenge, invalid signature, or wallet already linked to another account
- `404` - No active session for the given socketId
**Response (wallet already owned an account, so the caller is signed in to it):**
```json
{
"success": true,
"linked": true,
"adopted": true,
"sessionToken": "<rotated bearer token for the wallet's account>",
"address": "<that account's payout address or null>",
"message": "Signed in to your wallet-linked account."
}
```
**Errors** are returned as `{ "error": "<CODE>", "message": "<safe text>" }`:
| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | Missing or oversized `socketId`, missing proof fields, invalid/expired/already-used challenge, unverifiable event or signature |
| 401 | `SESSION_TOKEN_REQUIRED` | No `X-Session-Token` header |
| 403 | `SESSION_OWNERSHIP_REQUIRED` | Token does not own the given `socketId` |
| 409 | `WALLET_ADOPTION_CONFLICT`, `SOCKET_OWNERSHIP_CONFLICT` | Concurrent sign-in raced the adoption transaction; retry |
| 429 | rate limited | More than 20 verify attempts per minute from one IP; `Retry-After` is set |
### GET `/api/auth/smirk/status`
Check if a session has a linked wallet.
Report whether the caller's own account has a linked wallet.
**Query:** `?socketId=<socket.io id>`
**Headers:** `X-Session-Token: <session token>`
**Response:**
```json
{ "linked": true, "hasPayoutAddress": true }
```
The caller is identified by the session token alone, because `socket_id` is volatile and changes on
every refresh or reconnect; matching on the unguessable token gives the same guarantee (a caller can
only read its own state) without breaking after a reload. `socketId` remains a required query
parameter for API shape. A missing token returns 401 and an unknown token returns 403; neither
discloses anything about another account.
---
## Signature Verification
## Account adoption
The Smirk extension signs messages using Ed25519 with a **SHA256 prehash**:
`smirk_public_key` carries a unique index over non-null values, so one wallet key maps to at most one
account. When the proven key already belongs to a different account, verification does not fail.
Instead a single transaction:
1. Extension receives the challenge string (e.g. `"a1b2c3..."`)
2. Extension computes `msgHash = SHA256(UTF8_encode(challenge))`
3. Extension signs `msgHash` with the Ed25519 private spend key
4. Extension returns the 64-byte signature as hex
1. Re-locks and re-checks the presented session row.
2. Revokes the caller's temporary anonymous account (clears its `socket_id`, rotates its token).
3. Rotates the wallet owner's bearer token and assigns the caller's socket to that account.
4. Confirms exactly one account now owns the socket.
The server must verify against the same prehash:
The response carries the rotated token as `sessionToken` with `adopted: true`. Live sockets for both
accounts are disconnected after the response is sent, so nothing keeps running under stale identity.
The client stores the new token in `localStorage` and reloads, which re-establishes credits, payout
address, and history as the wallet's account.
---
## Proof verification
### NIP-98 (`src/utils/nip98.js`)
The challenge nonce is consumed first, atomically. A single
`UPDATE ... WHERE used = FALSE ... RETURNING` flips the row for exactly one caller, so concurrent
requests cannot both pass and there is no select-then-update window. The event is then verified
against that exact nonce.
Verification requires all of:
- Well-formed kind `27235` event: numeric `kind` and `created_at`, 64-hex `pubkey` and `id`, 128-hex
`sig`, string `content`, and `tags` as an array of string arrays.
- The event id independently recomputed with `getEventHash` and matching the claimed id. This
catches content or tag tampering without relying on any verification memoization inside
nostr-tools, which stamps its result onto the event object.
- A valid BIP-340 schnorr signature over secp256k1.
- `|now - created_at| <= 120` seconds.
- Exactly one `method` tag equal to `POST`.
- Exactly one `u` tag whose URL path ends with `/api/auth/smirk/verify` and whose host equals the
expected host.
- Exactly one `challenge` tag equal to the consumed nonce.
The expected host comes from `HOSTED_BY` when configured, falling back to the request's own host for
development and self-hosted deployments. The host check is a hard requirement, not a nicety: path
matching alone would accept an event the same wallet signed for any other site whose path ends the
same way, and because verification adopts whichever account owns the key and returns its session
token, a borrowed event would be a full account takeover.
Verification is a pure function with no I/O, so it round-trips in unit tests against nostr-tools.
The proven `pubkey` is an x-only secp256k1 nostr key, a different key namespace from the legacy
Ed25519 spend key, so a NIP-98 sign-in registers the account under the nostr key.
### Legacy Ed25519
The nonce is consumed the same way, then the signature is verified over the raw UTF-8 bytes of the
challenge string per RFC 8032:
```javascript
const crypto = require('crypto');
const nacl = require('tweetnacl');
const challengeHash = crypto.createHash('sha256').update(challenge).digest();
const challengeBytes = Buffer.from(challenge, 'utf8');
const signatureBytes = Buffer.from(signature, 'hex');
const publicKeyBytes = Buffer.from(publicKey, 'hex');
const valid = nacl.sign.detached.verify(challengeHash, signatureBytes, publicKeyBytes);
const valid = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes);
```
**Important:** Do NOT verify against `Buffer.from(challenge, 'hex')` (the raw hex-decoded bytes). The extension always SHA256-hashes the message string before signing.
There is no SHA256 pre-hash and no hex-decode of the challenge: the bytes signed are the bytes of
the challenge string itself.
---
## `window.smirk` API Reference
The extension injects a frozen `window.smirk` object on all pages.
## `window.smirk` API reference
### Detection
@@ -136,9 +232,13 @@ if (typeof window.smirk !== 'undefined') {
}
```
Capabilities are feature-detected individually. Nostr support is detected as
`typeof window.smirk.signNostrEvent === 'function' && typeof window.smirk.getNostrPublicKey === 'function'`.
### `smirk.connect()` -> `Promise<PublicKeys>`
Requests user approval to share public keys. Opens an approval popup on first use; subsequent calls from an approved origin return keys immediately.
Requests user approval to share public keys. Opens an approval popup on first use; later calls from
an approved origin return keys immediately.
```javascript
const keys = await window.smirk.connect();
@@ -162,6 +262,18 @@ const wowSig = result.signatures.find(s => s.asset === 'wow');
Message limit: 10,000 characters.
### `smirk.getNostrPublicKey()` -> `Promise<string>`
Returns the wallet's x-only nostr public key and grants the origin the Nostr scope. The grant is
one-time per origin.
### `smirk.signNostrEvent(template)` -> `Promise<Event>`
Signs a nostr event template, filling in `pubkey`, `id`, and `sig`. Throws `NOT_AUTHORIZED` when the
origin does not yet hold the Nostr scope. Both call sites therefore sign first and only call
`getNostrPublicKey()` on that error, so a returning user sees one approval rather than two. The
wallet still asks per signature for kind 27235; that prompt cannot be suppressed.
### `smirk.getAddresses()` -> `Promise<Addresses>`
Returns wallet addresses for all assets. Requires prior `connect()`.
@@ -177,28 +289,31 @@ const addrs = await window.smirk.getAddresses();
### `smirk.requestPayment(options)` -> `Promise<PaymentResult>`
Prompts the user to send a payment from their Smirk wallet. Opens an approval popup showing the recipient address, amount, and description. Resolves when the user confirms and the TX is broadcast; rejects if the user denies or the extension context is invalid.
Prompts the user to send a payment. Opens an approval popup showing the recipient address, amount,
and description. Resolves once the user confirms and the transaction is broadcast; rejects if the
user denies or the extension context is invalid.
```javascript
const result = await window.smirk.requestPayment({
address: 'Wo3MWeLE...', // Recipient address
amount: '1', // Human-readable amount (NOT atomic units)
asset: 'wow', // Asset ticker (lowercase)
description: 'Single game entry' // Shown in approval popup
address: 'Wo3MWeLE...', // Recipient address
amount: '1', // Human-readable amount, not atomic units
asset: 'wow', // Asset ticker, lowercase
description: 'Single game entry' // Shown in the approval popup
});
// result.txid - Transaction hash (hex)
// result.amount - Amount sent (string)
```
**Important:** The `amount` field must be the human-readable value (e.g. `"1"` for 1 WOW), not atomic units. The extension handles conversion internally.
`amount` is the human-readable value, for example `"1"` for 1 WOW. The extension converts to atomic
units internally.
**Error cases:**
- User clicks "Deny" → rejects with user cancellation error
- Extension context invalidated (browser reloaded extension) → rejects with `"Extension context invalidated"`. The page must be refreshed to re-establish the connection.
Rejection cases: the user denies the popup, or the extension context is invalidated (the browser
reloaded the extension), which rejects with `"Extension context invalidated"` and requires a page
refresh to re-establish the connection.
### `smirk.isConnected()` -> `Promise<boolean>`
Check if the current origin is connected (approved).
Whether the current origin is approved.
### `smirk.disconnect()` -> `Promise<void>`
@@ -206,47 +321,83 @@ Revoke site access.
### `smirk.getPublicKeys()` -> `Promise<PublicKeys | null>`
Get public keys without prompting (only works if already connected). Returns `null` if not connected.
Public keys without prompting. Returns `null` when the origin is not connected.
---
## Client-Side Implementation
## Client implementation
See `html/js/network/smirkAuth.js` for auth and `html/js/network/socketHandlers.js` (`_trySmirkPayment`) for payments. Key points:
**Sign-in:** `html/js/network/smirkAuth.js`.
- The `SmirkAuth` module is initialized after DOM ready and socket connection
- It checks `SocketHandlers._smirkEnabled` (set by server's `game_mode_info` event) to gate the feature
- On successful auth, it calls `smirk.getAddresses()` to auto-set the payout address via `socket.emit('address:update')`
- The button shows install link if extension is not detected, connect button if it is
- When a Smirk-connected user triggers a payment, `_trySmirkPayment()` calls `smirk.requestPayment()` with the human-readable amount. If the user confirms, the TX is broadcast and the server's existing mempool monitoring detects it. If the user denies or the extension context is stale, it falls back to the normal address/QR payment modal.
- `SmirkAuth.init()` runs when `game_mode_info` arrives, with a 3-second fallback timer, and only
when the server advertised `smirkEnabled === true`. Double initialization is guarded.
- With the extension present it renders a connect button and calls `checkStatus()` to show the
already-linked state on load; without it, an install link to `https://smirk.cash` is shown.
- `login()` fetches a challenge, runs the NIP-98 or legacy proof, and on an `adopted` response stores
the returned `sessionToken` and reloads the page.
- Otherwise it calls `getAddresses()` and emits `address:update` with `addresses.wow` to set the
payout address. A failure here is non-fatal: the user is told to set the address manually.
- A `LOCKED` error is surfaced as an unlock prompt rather than a failure, because current wallet
builds open the unlock popup from `connect()` and `signNostrEvent()` while older builds throw.
**Payments:** `_trySmirkPayment` in `html/js/network/socketHandlers.js`.
- On the `payment_created` event, the client uses `requestPayment()` when `SmirkAuth._isLinked` is
true, the extension is present, and it exposes `requestPayment`.
- The amount sent is `data.humanAmount || data.amountFormatted || String(data.amount)`, and the asset
is the active crypto type lowercased. The description varies by payment type: game entry, credits
package, or cosmetic pack.
- On success the normal server-side mempool monitoring completes the flow; the UI moves to a
waiting-for-confirmation state.
- On rejection the client falls back to the address and QR modal. A user denial keeps the linked
state; any other error clears `SmirkAuth._isLinked` so later payments go straight to the manual
flow.
**Chat signing:** `html/js/smirkChatSign.js`.
- `smirkChatSign(socket, text, tag)` signs a kind:1 event carrying a `t` tag (default
`wowngeon-global`) and emits it as `chat_signed`.
- It resolves `false` whenever signing is unavailable or declined, so callers fall back to the normal
unsigned send. Feature detection is end to end, which makes wiring it into a send site safe even
when no wallet is connected.
- The server verifies the event in `src/utils/verifyChatEvent.js` and binds the author to the npub
the session authenticated as, so a player can only post as themselves. A valid signature by some
other key is rejected.
---
## Database
The `smirk_challenges` table stores pending challenges:
`smirk_challenges` holds pending nonces (migrations `009_smirk_integration.sql` and
`036_smirk_challenge_user_binding.sql`):
| Column | Type | Description |
|--------|------|-------------|
| id | serial | Primary key |
| challenge | text | 64-char hex challenge string |
| socket_id | text | Socket.io session ID |
| used | boolean | Whether challenge has been consumed |
| expires_at | timestamp | Auto-set to NOW() + 5 minutes |
| challenge | varchar(64), unique | Hex challenge string |
| socket_id | varchar(255) | Socket.IO session id the nonce was issued to |
| user_id | integer, not null | Owning `users.id`, cascade delete |
| used | boolean | Whether the nonce has been consumed |
| expires_at | timestamp | Defaults to `NOW() + 5 minutes` |
| created_at | timestamp | Row creation time |
The `users` table has a `smirk_public_key` column that stores the linked Ed25519 public key (hex).
`users.smirk_public_key` (`varchar(128)`) stores the linked key, with a unique index over non-null
values. Migration 036 also adds a unique index on non-null `users.socket_id`, making single socket
ownership structural; the migration aborts if duplicate rows exist.
---
## Signature Schemes by Asset
## Signature schemes by asset
| Asset | Curve | Hash | Format |
|-------|-------|------|--------|
| BTC | secp256k1 (ECDSA) | Double SHA256 with Bitcoin message prefix | Compact signature (64 bytes) |
| LTC | secp256k1 (ECDSA) | Double SHA256 with Bitcoin message prefix | Compact signature (64 bytes) |
| XMR | Ed25519 | SHA256 prehash | R \|\| s (64 bytes) |
| WOW | Ed25519 | SHA256 prehash | R \|\| s (64 bytes) |
| GRIN | Ed25519 | SHA256 prehash | R \|\| s (64 bytes) |
| XMR | Ed25519 | Raw message bytes (RFC 8032) | R \|\| s (64 bytes) |
| WOW | Ed25519 | Raw message bytes (RFC 8032) | R \|\| s (64 bytes) |
| GRIN | Ed25519 | Raw message bytes (RFC 8032) | R \|\| s (64 bytes) |
Wownerogue only uses the WOW signature for authentication.
Nostr signing (`signNostrEvent`) is separate from all of these: BIP-340 schnorr over secp256k1 with
an x-only public key.
Wowngeon's legacy sign-in path uses only the WOW signature. The NIP-98 path uses only the nostr key.
+199 -125
View File
@@ -1,185 +1,259 @@
# Logs and Backup Configuration
# Logs and Backup
This document covers log management and database backup strategies for production deployment.
Operational reference for application logs and PostgreSQL backups in a production deployment.
---
## Log Management with systemd/journald
## Application Logs
When running as a systemd service, logs automatically go to journald which handles rotation. Configure retention limits as needed.
The server has no logging library and writes no log files. All diagnostics go to stdout and stderr,
so under systemd they land in the journal of the unit that started the process. Log retention is
therefore a journald configuration question, not an application one.
### Configure Journal Limits
Create `/etc/systemd/journald.conf.d/wownerogue.conf`:
```ini
[Journal]
# Max disk usage for all journals
SystemMaxUse=500M
# Max size per journal file
SystemMaxFileSize=50M
# Keep logs for 30 days max
MaxRetentionSec=30day
# Compress older entries
Compress=yes
```
Apply the configuration:
```bash
sudo mkdir -p /etc/systemd/journald.conf.d/
sudo nano /etc/systemd/journald.conf.d/wownerogue.conf
# paste the config above
sudo systemctl restart systemd-journald
```
The repository ships two application units in `scripts/deploy/`: `wownerogue.service` (Wownero
mainnet instance) and `monerogue.service` (Monero stagenet instance).
### Viewing Logs
```bash
# All logs for the service
# All logs for a unit
journalctl -u wownerogue
# Follow live (like tail -f)
# Follow live
journalctl -u wownerogue -f
# Last 100 lines
journalctl -u wownerogue -n 100
# Logs since today
# Since today
journalctl -u wownerogue --since today
# Logs from a specific time range
journalctl -u wownerogue --since "2025-12-01" --until "2025-12-15"
# A specific time range
journalctl -u wownerogue --since "2026-01-01" --until "2026-01-15"
# Export to file for analysis
journalctl -u wownerogue --since "2025-12-01" > wownerogue-dec.log
# Show only errors
# Errors only
journalctl -u wownerogue -p err
# Export for offline analysis
journalctl -u wownerogue --since "2026-01-01" > wownerogue-jan.log
```
The same commands work for `monerogue`, the wallet-RPC units, and the backup unit.
### Journal Retention
Journald limits apply to the whole journal, not to an individual unit, so these settings bound
every service on the host. Create a drop-in such as `/etc/systemd/journald.conf.d/limits.conf`:
```ini
[Journal]
# Total disk budget for all journals
SystemMaxUse=500M
# Size cap per journal file
SystemMaxFileSize=50M
# Discard entries older than this
MaxRetentionSec=30day
Compress=yes
```
Apply it:
```bash
sudo mkdir -p /etc/systemd/journald.conf.d/
sudo install -m 0644 limits.conf /etc/systemd/journald.conf.d/limits.conf
sudo systemctl restart systemd-journald
```
Journald is a rolling operational log, not an audit archive. Records that must survive log
rotation belong in the database. Accounting events are exported through the durable outbox
described in [FINANCIAL_EVENT_EXPORT.md](./FINANCIAL_EVENT_EXPORT.md).
---
## PostgreSQL Automated Backups
## PostgreSQL Backups
### Backup Script
### The Validated Backup Job
The backup script is located at `scripts/backup_db.sh`. It reads database credentials from environment variables (same as your `.env` file).
`scripts/deploy/wowngeon-db-backup.sh`, with its `wowngeon-db-backup.service` and
`wowngeon-db-backup.timer` units, is the production backup path. Each run:
**Required environment variables:**
- `DB_NAME` - Database name (default: `wownerogue`)
- `DB_USER` - Database user
- `DB_HOST` - Database host (default: `localhost`)
- `BACKUP_DIR` - Where to store backups (default: `/var/backups/wownerogue`)
- `RETENTION_DAYS` - How long to keep backups (default: `30`)
- Dumps every database named in the script's loop. The shipped list is `wownerogue` and
`monerogue`; a single-instance operator edits that loop to match the databases actually present.
- Writes custom-format dumps (`pg_dump --format=custom --no-owner`) to a temporary file, verifies
the catalog with `pg_restore --list`, sets mode `0600`, then renames into place, so a reader never
observes a partial dump under its final name.
- Records a SHA-256 sidecar next to each dump, also mode `0600`.
- Deletes dumps and sidecars older than `BACKUP_RETENTION_DAYS`, and stale `.tmp` files older than
one day.
### systemd Timer (Recommended)
Configuration comes from two environment variables:
Use systemd timers for scheduled backups. Copy the service files to `/etc/systemd/system/`:
| Variable | Default | Behaviour |
| --- | --- | --- |
| `BACKUP_ROOT` | `/var/backups/wowngeon/daily` | The script refuses to run against any other value. Changing the destination requires a reviewed change to the script itself. |
| `BACKUP_RETENTION_DAYS` | `14` | Must be a positive integer. The service unit sets it explicitly to `14`. |
1. **Service file** (`wownerogue-backup.service`):
- Located at `scripts/wownerogue-backup.service`
- Runs the backup script with environment from your `.env` file
The script also exits if `BACKUP_ROOT` does not exist or is a symlink, so a misconfigured or
tampered path fails loudly instead of writing dumps somewhere unexpected.
2. **Timer file** (`wownerogue-backup.timer`):
- Located at `scripts/wownerogue-backup.timer`
- Runs daily at 3 AM with random delay up to 15 minutes
Retention is deliberately short because this directory sits on the same disk as the database and
exists for fast local recovery. Long-horizon history is the job of an independent, separately
encrypted off-host archive, which the operator configures according to their recovery policy.
### Setup Commands
The service unit runs as `postgres` with `UMask=0077` and a hardened sandbox: `ProtectSystem=strict`
with `ReadWritePaths=/var/backups/wowngeon/daily` as the only writable location, an empty capability
bounding set, and `RestrictAddressFamilies=AF_UNIX` so the job can reach the local PostgreSQL socket
and nothing on the network. Because it authenticates as the `postgres` OS user over that socket, it
needs no database password and reads no application `.env` file.
The timer fires daily at 03:20 local time with a randomized delay of up to 20 minutes, and
`Persistent=true` so a run missed while the host was off happens at the next boot.
### Installation
Install these artifacts through the operator's reviewed change process rather than from the runtime
release directory, matching the boundary described in [DEPLOY.md](./DEPLOY.md). The service unit
expects the script at `/usr/local/sbin/wowngeon-db-backup`.
```bash
# Create backup directory
sudo mkdir -p /var/backups/wownerogue
sudo chown $(whoami):$(whoami) /var/backups/wownerogue
# Backup destination, owned by the database user only
sudo install -d -o postgres -g postgres -m 0700 /var/backups/wowngeon/daily
# Make backup script executable
chmod +x scripts/backup_db.sh
# Script and units
sudo install -o root -g root -m 0755 \
scripts/deploy/wowngeon-db-backup.sh /usr/local/sbin/wowngeon-db-backup
sudo install -o root -g root -m 0644 \
scripts/deploy/wowngeon-db-backup.service /etc/systemd/system/
sudo install -o root -g root -m 0644 \
scripts/deploy/wowngeon-db-backup.timer /etc/systemd/system/
# Copy systemd files
sudo cp scripts/wownerogue-backup.service /etc/systemd/system/
sudo cp scripts/wownerogue-backup.timer /etc/systemd/system/
# Edit the service file to point to your installation path
sudo nano /etc/systemd/system/wownerogue-backup.service
# Update WorkingDirectory and EnvironmentFile paths
# Reload systemd and enable timer
sudo systemctl daemon-reload
sudo systemctl enable wownerogue-backup.timer
sudo systemctl start wownerogue-backup.timer
sudo systemctl enable --now wowngeon-db-backup.timer
# Verify timer is scheduled
systemctl list-timers | grep wownerogue
# Confirm the schedule
systemctl list-timers wowngeon-db-backup.timer
```
### Manual Backup
### Running a Backup on Demand
```bash
# Run backup manually
cd /path/to/wownerogue/src
source .env
../scripts/backup_db.sh
# Or via systemd
sudo systemctl start wownerogue-backup.service
journalctl -u wownerogue-backup.service -n 20
sudo systemctl start wowngeon-db-backup.service
journalctl -u wowngeon-db-backup.service -n 50
ls -lh /var/backups/wowngeon/daily/
```
### Restore from Backup
Dumps are named `<database>-<UTC timestamp>.dump`, for example
`wownerogue-20260726T032013Z.dump`.
### Verifying a Backup
Check integrity before trusting a dump:
```bash
# List available backups
ls -lh /var/backups/wownerogue/
cd /var/backups/wowngeon/daily
sudo -u postgres sha256sum -c wownerogue-20260726T032013Z.dump.sha256
sudo -u postgres pg_restore --list wownerogue-20260726T032013Z.dump | head
```
# Restore a specific backup (replace filename with actual backup)
gunzip -c /var/backups/wownerogue/wownerogue_20251222_030000.sql.gz | psql -h $DB_HOST -U $DB_USER -d $DB_NAME
### Restoring
# Or with explicit values
gunzip -c /var/backups/wownerogue/wownerogue_20251222_030000.sql.gz | psql -h localhost -U your_user -d wownerogue
Custom-format dumps restore with `pg_restore`, not `psql`. Restore into a scratch database first
and promote only after inspection.
```bash
# Restore into a fresh database
sudo -u postgres createdb -O wownerogue wownerogue_restore
sudo -u postgres pg_restore --no-owner --exit-on-error \
--dbname=wownerogue_restore /var/backups/wowngeon/daily/wownerogue-20260726T032013Z.dump
# Sanity-check the restored copy
sudo -u postgres psql -d wownerogue_restore -c "SELECT COUNT(*) FROM users;"
sudo -u postgres psql -d wownerogue_restore -c \
"SELECT filename FROM schema_migrations ORDER BY filename DESC LIMIT 1;"
# Discard the scratch copy when finished
sudo -u postgres dropdb wownerogue_restore
```
Restoring over a live database requires stopping the application unit first, because the server
runs migrations at startup and holds pooled connections:
```bash
sudo systemctl stop wownerogue
sudo -u postgres pg_restore --no-owner --clean --if-exists --exit-on-error \
--dbname=wownerogue /var/backups/wowngeon/daily/wownerogue-20260726T032013Z.dump
sudo systemctl start wownerogue
```
Rehearse this path on a restored copy periodically. A backup that has never been restored is an
untested assumption. The disposable clone migration gate in
[CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md) exercises restore plus the full migration set and is
the natural place to combine the two rehearsals.
---
## Simple Single-Database Alternative
`scripts/backup_db.sh` is a smaller portable script for operators who do not want the hardened
job: one database, gzipped plain SQL, credentials from the environment. Its companion units are
`scripts/wownerogue-backup.service` and `scripts/wownerogue-backup.timer` (daily at 03:00 with up
to 15 minutes of jitter).
| Variable | Default |
| --- | --- |
| `DB_NAME` | `wownerogue` |
| `DB_USER` | `postgres` |
| `DB_HOST` | `localhost` |
| `DB_PORT` | `5432` |
| `BACKUP_DIR` | `/var/backups/wownerogue` |
| `RETENTION_DAYS` | `30` |
The first five match the application's own database settings in `src/.env.example`, so the service
unit loads that file directly with `EnvironmentFile=`. Each run writes
`<DB_NAME>_<YYYYmmdd_HHMMSS>.sql.gz`, refreshes a `<DB_NAME>_LATEST.sql.gz` symlink, deletes a
partial file if `pg_dump` fails, and prunes dumps older than `RETENTION_DAYS`.
Because it connects over TCP as a database role, it needs working credentials, typically a
`~/.pgpass` entry or `PGPASSWORD` in the unit environment. Plain-SQL dumps restore with `psql`:
```bash
gunzip -c /var/backups/wownerogue/wownerogue_LATEST.sql.gz \
| psql -h "$DB_HOST" -U "$DB_USER" -d wownerogue_restore
```
A cron equivalent, for hosts without systemd timers:
```cron
0 3 * * * cd /var/www/wownerogue/src && set -a && . ./.env && set +a && /var/www/wownerogue/scripts/backup_db.sh >> /var/log/wownerogue-backup.log 2>&1
```
---
## Alternative: Cron-based Backups
## What Database Backups Do Not Cover
If you prefer cron over systemd timers:
A database dump alone does not restore a working instance. Back up these separately, with access
controls at least as strict as the dumps:
```bash
# Edit crontab
crontab -e
- **Wallet keyfiles and their passwords.** The wallet-RPC units keep wallet state under
`/var/lib/<instance>-wallet` (see `scripts/deploy/wownero-wallet-rpc.conf.example`). Losing these
loses the funds; the database records balances, never keys.
- **Instance secrets.** `/etc/wownerogue/app.env`, `/etc/wownerogue/wallet-rpc.env`, and the
equivalents for other instances. `src/.env.example` is the full annotated template for what these
contain.
- **Reverse proxy and TLS configuration**, which lives outside the application tree.
# Add this line (runs daily at 3 AM)
0 3 * * * cd /path/to/wownerogue/src && source .env && ../scripts/backup_db.sh >> /var/log/wownerogue-backup.log 2>&1
```
Keep at least one copy of everything above off the host that runs the database. Same-disk backups
protect against operator error, not against losing the machine.
---
## Backup Verification
## Monitoring Checklist
Periodically verify your backups work:
```bash
# Create a test database
createdb -h localhost -U $DB_USER wownerogue_test
# Restore backup to test database
gunzip -c /var/backups/wownerogue/wownerogue_LATEST.sql.gz | psql -h localhost -U $DB_USER -d wownerogue_test
# Verify data
psql -h localhost -U $DB_USER -d wownerogue_test -c "SELECT COUNT(*) FROM users;"
# Drop test database
dropdb -h localhost -U $DB_USER wownerogue_test
```
---
## Monitoring Backup Health
Add to your monitoring checklist:
- [ ] Check backup timer is active: `systemctl status wownerogue-backup.timer`
- [ ] Verify recent backups exist: `ls -la /var/backups/wownerogue/ | tail -5`
- [ ] Check backup sizes are reasonable (not 0 bytes)
- [ ] Periodically test restore process
- [ ] Monitor disk space: `df -h /var/backups`
- Timer is active and scheduled: `systemctl list-timers wowngeon-db-backup.timer`
- Last run succeeded: `systemctl status wowngeon-db-backup.service`
- Recent dumps exist and are non-empty: `ls -lh /var/backups/wowngeon/daily/ | tail`
- Checksums verify: `sha256sum -c` on the newest sidecar
- Dump sizes track database growth rather than dropping sharply
- Backup volume has headroom: `df -h /var/backups`
- Off-host copies are current
- A restore rehearsal has happened recently enough to be meaningful
+176 -140
View File
@@ -1,9 +1,10 @@
# Match Mode — Race/PvP Multiplayer
# Match Mode: Real-Time Multiplayer Races
> Status: **Implemented** — all five milestones complete. Single-player and Tavern modes are untouched.
Match mode adds real-time multiplayer races. It reuses the shared-world engine (`Room` / `Occupant`),
the render kit, the payment and payout systems, and provably-fair seeding. Single-player and Tavern
modes are unaffected by it.
Match mode adds real-time multiplayer races to Wownerogue. It reuses the existing shared-world
engine (`Room`/`Occupant`), the render kit, the payment/payout systems, and provably-fair seeding.
Match mode is opt-in. Nothing in this document is active unless `MATCH_ENABLED=true`.
## Supported economies
@@ -11,37 +12,43 @@ engine (`Room`/`Occupant`), the render kit, the payment/payout systems, and prov
|---|---|---|---|
| `free` | None | None | Pleb board |
| `credits_prestige` | Credits | None (prestige only) | Prestige board |
| `crypto_race` | Race-entry ticket | Winner-take-pot (minus house fee) | Hall of Champions |
| `crypto_race` | Race-entry ticket | Winner-take-pot minus house fee | Hall of Champions |
Unredeemable credits are the same `users.credits` balance running with `CREDITS_PAYOUTS_ENABLED=false`;
there is no separate "fake credit" system. Crypto races use non-refundable **race-entry tickets**
(`users.race_entries`) so players never need an on-chain refund when leaving a queue before a match
starts.
`credits_prestige` spends the same `users.credits` balance used everywhere else; there is no separate
"fake credit" system. On an instance running `CREDITS_PAYOUTS_ENABLED=false` those credits are
unredeemable, so the economy is prestige-only.
## Lifecycle: a race every block
Crypto races spend non-refundable race-entry tickets (`users.race_entries`, backed by rows in
`race_entry_lots`), so leaving a queue before a match starts never requires an on-chain refund.
The existing solo `queueHandler` already reacts to new crypto blocks. `MatchScheduler` subscribes to
the same block-count event. Free and value-bearing queues intentionally have different starts:
## Lifecycle
- A free queue with ≥2 players drains into a `MatchRoom` immediately on the current block event.
- A paid queue with ≥2 players at canonical header H atomically creates a `pending` match and links
the exact FIFO queue rows and entrants. That transaction records a deterministic entrant-freeze
commitment and fixes canonical header H+the configured entropy delay as its target; it does not
read a block hash or create a playable seed. A fresh post-commit count is persisted while the
`MatchScheduler` subscribes to the same block-count event as the solo `queueHandler`. Free and
value-bearing queues start differently on purpose.
- A free queue with at least the ruleset minimum of players drains into a `MatchRoom` immediately on
the current block event.
- A paid queue at canonical header H atomically creates a `pending` match and links the exact FIFO
queue rows and entrants. That transaction records a deterministic entrant-freeze commitment and
fixes canonical header `H + MATCH_PAID_ENTROPY_DELAY_BLOCKS` as its target; it does not read a
block hash or create a playable seed. A fresh post-commit daemon count is persisted while the
target is still in the future.
- Only a later, sufficiently confirmed block-count event can resolve that pending match. The
scheduler twice requests the canonical hash for the exact target, verifies the frozen IDs again,
derives the seed, and changes the match from `pending` to `starting` before collection/countdown.
It immediately checks the hash again. Another height and server randomness are not substitutes.
- If a queue has 1 player, they carry over and may leave until a freeze claims their row.
scheduler requests the canonical hash for the exact target twice, verifies the frozen IDs again,
derives the seed, and moves the match from `pending` to `starting` before ticket collection and
countdown. It re-reads the hash immediately after activation. Another height and server randomness
are not substitutes.
- If a queue is below the minimum player count, its members carry over and may leave until a freeze
claims their row.
- If a queue is empty, nothing happens.
A match ends when:
1. Its selected ruleset resolves (for example, first exit, last alive, or all players resolved).
2. If `ruleset.timing.blockDeadline` is enabled, the first advancing canonical header after both
the match's start header and `MATCH_MIN_DURATION_MS` of active play is observed. A duplicate
block poll, the start header itself, or a block arriving before the floor cannot end the match;
after an early block, the match waits for the next advancing header.
1. Its selected ruleset resolves (for example first exit, last alive, or all players resolved).
2. `ruleset.timing.blockDeadline` is enabled and the first advancing canonical header is observed
after both the match's start header and `MATCH_MIN_DURATION_MS` of active play. A duplicate block
poll, the start header itself, or a block arriving before the floor cannot end the match; after an
early block the match waits for the next advancing header.
3. The `MATCH_HARD_CEILING_MS` absolute ceiling expires.
Final placement and `winnerId` come from the selected ruleset's deterministic rank strategy. The
@@ -52,82 +59,111 @@ rankings.
### Engine layer (`src/multiplayer/`)
- `MatchRoom.js` — extends `Room`. Owns a shared deterministic dungeon, shared monster, player
life/death/finish state, treasure, and the block/timer deadline. Serves as the synchronous tick
resolver.
- `MatchEngine.js` — server tick driver. Wraps a `MatchRoom` with a `setInterval` timer, calls
- `MatchRoom.js` extends `Room`. Owns the shared deterministic dungeon, the shared monster when the
ruleset includes one, player life/death/finish state, treasure, and the block/timer deadline. Acts
as the synchronous tick resolver.
- `MatchEngine.js` is the server tick driver. It wraps a `MatchRoom` with an interval timer, calls
`resolveTick()`, and fires `onFinish` when the match ends.
- `MatchState.js` — serialization helpers that produce broadcast/persistence shapes, including the
renderer-agnostic `gameState` consumed by `html/js/render/sceneModel.js`.
- `MatchState.js` holds serialization helpers that produce broadcast and persistence shapes,
including the renderer-agnostic `gameState` consumed by `html/js/render/sceneModel.js`.
### Transport / lifecycle layer (`src/network/`)
### Transport and lifecycle layer (`src/network/`)
- `matchQueue.js` — persisted per-economy queues (`free`, `credits_prestige`, `crypto_race`),
join/leave, atomic credit/ticket escrow.
- `matchScheduler.js` — block-cadence scheduler that drains queues into matches.
- `matchManager.js` — Socket.IO rooms, match lifecycle, broadcasts, reconnect grace, persistence.
- `matchPayoutService.js` — crypto-race pot accounting, house-fee split, winner payout record.
- `matchLeaderboard.js` — posts match scores to the correct leaderboard.
- `tavernMatchBridge.js` — forwards public match state to the tavern room for spectators.
- `matchQueue.js` persists per-economy queues (`free`, `credits_prestige`, `crypto_race`), handles
join and leave, and performs atomic credit and ticket-lot escrow.
- `matchScheduler.js` is the block-cadence scheduler that freezes, activates, and drains queues into
matches.
- `matchFairness.js` builds and verifies the paid entrant-freeze commitment and derives match seeds.
- `matchEconomyPolicy.js` is the single fail-closed admission contract for crypto matches, shared by
the queue, scheduler, mode advertisement, and environment validator.
- `matchManager.js` owns Socket.IO rooms, match lifecycle, broadcasts, reconnect grace, and
persistence.
- `matchPayoutService.js` handles crypto-race pot accounting, house-fee split, and the winner payout
record.
- `matchLeaderboard.js` posts match scores to the correct leaderboard.
- `tavernMatchBridge.js` forwards public match state to the tavern room for spectators.
### Rulesets (`src/game/rulesets/`)
Built-in match rulesets are `race`, `last-alive`, `score-attack`, and `coop-escape`; `solo-classic`
is solo-only. `resolveMatchRuleset()` fails closed to `race` for an unknown or solo-only id. See
[RULESETS.md](RULESETS.md).
### Persistence
See migration `src/migrations/022_match_mode.sql`:
Schema lives in `src/migrations/`:
- `matches` — one race, including the durable `pending` entrant-freeze envelope and later
verifiable seed commitment, economy, pot/fee, winner.
- `match_entrants` — per-player state, placement, score, payment link.
- `match_events` — replay / spectator / audit feed.
- `match_queue_entries` — persisted queue for restart safety.
- `race_entry_transactions` — race-entry ticket ledger.
- `users.race_entries` — hot-read ticket balance.
- `payouts.match_id` — links winner payout to match.
- `022_match_mode.sql` creates `matches`, `match_entrants`, `match_events`, `match_queue_entries`,
and `race_entry_transactions`, adds `users.race_entries` and `payouts.match_id`.
- `029_match_ruleset_id.sql` records the ruleset that produced each match.
- `033_match_payout_liability.sql` adds `race_entry_lots` (per-payment funded ticket lots), links
queue entries and entrants to their exact lot, and makes the accepted match liability immutable.
- `040_paid_match_entropy_precommit.sql` adds `entropy_precommit_tip_height` and
`entropy_precommit_verified_at`, with a check constraint requiring the witnessed tip to be below
the target height for paid economies.
`032_solo_liability_invariants.sql` defines the unconditional unique index
`idx_payouts_one_per_match`.
Tables:
- `matches` is one race, including the `pending` entrant-freeze envelope, the verifiable seed
commitment, economy, pot and fee, and winner.
- `match_entrants` is per-player state, placement, score, and payment link.
- `match_events` is the replay, spectator, and audit feed.
- `match_queue_entries` is the persisted queue, for restart safety.
- `race_entry_transactions` is the race-entry ticket ledger.
### Match seed verification
Paid matches use `future-block-freeze-v2` / `future-chain-block-v2`. At canonical header H the
server commits the exact economy, ruleset, sorted durable FIFO queue-entry IDs, configured delay,
and target `H + MATCH_PAID_ENTROPY_DELAY_BLOCKS` in a `pending` row before requesting any hash.
After that transaction commits, a fresh strict daemon count must prove that the target does not yet
exist; the witnessed tip and verification timestamp are durable and immutable. A delayed commit
that reaches the target is cancelled and every exact escrow anchor is refunded without reading the
target hash. Legacy v1 or otherwise unverified pending freezes are also refunded at startup.
Paid matches use the `future-block-freeze-v2` freeze and `future-chain-block-v2` seed versions. At
canonical header H the server commits the exact economy, ruleset, sorted durable FIFO queue-entry
IDs, configured delay, and target `H + MATCH_PAID_ENTROPY_DELAY_BLOCKS` into a `pending` row before
requesting any hash. After that transaction commits, a fresh strict daemon count must prove the
target does not yet exist; the witnessed tip and verification timestamp are durable and immutable. A
delayed commit that reaches the target is cancelled and every exact escrow anchor is refunded without
reading the target hash. Legacy v1 and otherwise unverified pending freezes are refunded at startup.
Activation waits until the target itself has the configured confirmation depth (the target counts
as confirmation one). The scheduler reads count/header/count/header and requires both exact-height
headers to have the same canonical hash. It re-reads that target immediately after the activation
transaction and before ticket collection, gameplay, or client notification; an unavailable or
changed hash aborts and refunds the match. The playable seed is SHA-256-derived only from the v2
freeze commitment and exact target hash. The persisted/public proof records the delay, required
Activation waits until the target itself has the configured confirmation depth, where the target
counts as confirmation one. The scheduler reads count, header, count, header, and requires both
exact-height headers to carry the same canonical hash. It re-reads that target immediately after the
activation transaction and before ticket collection, gameplay, or client notification; an unavailable
or changed hash aborts and refunds the match. The playable seed is SHA-256 derived only from the v2
freeze commitment and the exact target hash. The persisted public proof records the delay, required
confirmations, minimum activation tip, and post-commit witness. Confirmation settings are safety
metadata rather than extra seed material.
metadata, not extra seed material.
Monero-family `getblockcount` is a count, while `get_block(height)` uses zero-based header heights.
The scheduler normalizes the event exactly once (`observedHeaderHeight = blockCount - 1`). Every
persisted/disclosed `blockHeight` is the actual header height passed unchanged to `get_block`, so the
published height resolves to the published hash without an off-by-one translation.
Monero-family `getblockcount` returns a count while `get_block(height)` uses zero-based header
heights. The scheduler normalizes the event exactly once (`observedHeaderHeight = blockCount - 1`).
Every persisted or disclosed `blockHeight` is the actual header height passed unchanged to
`get_block`, so a published height resolves to the published hash with no off-by-one translation.
This proves the documented seed derivation and deterministic dungeon; it does not prove honest
input handling, block-source independence, payout delivery, or resistance to a malicious chain
producer/operator. Free development matches may use a server-random commitment and are labelled
`server commitment only`. Do not describe match mode as equivalent to the solo two-party fairness
Free matches derive their seed from the current block hash when one is available. When it is not,
they fall back to a server-random seed recorded as derivation version `server-random-v1`. Paid
economies have no such fallback: a paid match without a `future-chain-block-v2` proof is retained
rather than started.
This construction proves the documented seed derivation and the deterministic dungeon. It does not
prove honest input handling, block-source independence, payout delivery, or resistance to a malicious
chain producer or operator. Do not describe match mode as equivalent to the solo two-party fairness
protocol without an independent review.
## Configuration
Add to `src/.env` (documented in `src/.env.example`):
Set these in `src/.env`. The full annotated template is `src/.env.example`; a match-only excerpt is
`src/.env.match.example`.
```bash
# Enable match mode (default false)
MATCH_ENABLED=false
# Max players per match (also bounded by the selected ruleset; built-ins max at 8)
# Max players per match, also bounded by the selected ruleset (built-ins max at 8)
MATCH_MAX_PLAYERS=4
# Server tick interval in ms
MATCH_TICK_MS=250
# Server-selected gameplay ruleset; clients cannot override it
# Server-selected gameplay ruleset; clients cannot override it.
# race | last-alive | score-attack | coop-escape
MATCH_RULESET_ID=race
@@ -148,7 +184,7 @@ MATCH_CREDITS_COST=1
# Crypto race house fee percent (required; 0 <= fee < 100)
MATCH_HOUSE_FEE_PERCENT=5
# Required per-player funded ticket value in atomic units.
# Required per-player funded ticket value in atomic units
MATCH_ENTRY_FEE_ATOMIC=5000000000
# Crypto admission requires both explicit match gates and an outer atomic-unit payout cap
@@ -157,103 +193,103 @@ MATCH_PAYOUTS_ENABLED=false
MATCH_PAYOUT_MAX=50000000000
```
Crypto races also require:
- A paid instance (`GAME_MODE=PAID_SINGLE` / `PAID_CREDITS` or equivalent `PAYMENT_MODES`).
- At least one race-entry product with `grants.race_entries: N` and
`grants.race_entry_value_atomic` exactly equal to `MATCH_ENTRY_FEE_ATOMIC`. The product price
must cover `N * MATCH_ENTRY_FEE_ATOMIC`; confirmation records a durable lot per payment.
- A competitive single-winner ruleset. `coop-escape` is rejected until split-payout semantics are
implemented.
Crypto races additionally require:
- A paid instance (`GAME_MODE=PAID_SINGLE` or `PAID_CREDITS`, or the equivalent `PAYMENT_MODES`).
- At least one race-entry product with `grants.race_entries: N` and `grants.race_entry_value_atomic`
exactly equal to `MATCH_ENTRY_FEE_ATOMIC`. The product price must cover `N * MATCH_ENTRY_FEE_ATOMIC`;
confirmation records a durable lot per payment.
- A competitive single-winner ruleset. `coop-escape` is rejected because it has no
winner-takes-pot split contract.
## Client surfaces
- `/match.html` — dedicated race queue / race view with economy selector and race HUD.
- `/tavern.html` — **Queue for Race** panel and **Active Races** list with Watch buttons. Tavern
spectators receive `tavern_match_tick` / `tavern_match_end` events and render races through the
existing render kit.
- `/match.html` with `html/js/matchClient.js` is the dedicated race page: entry-mode selector,
ruleset catalog, race HUD, render-style picker, and touch d-pad.
- `/tavern.html` adds a Multiplayer Arena panel with the entry-mode selector and an active-match list
with Watch buttons. Tavern spectators receive `tavern_match_tick` and `tavern_match_end` and render
races through the render kit. A tavern viewer who is a player in the match keeps movement controls
instead of a spectator camera.
## Socket events
Server emits:
- `game_mode_info` — includes `modes.match { enabled, economies, maxPlayers, activeRuleset, rulesets }`.
- `match_queue_joined` / `match_queue_left` — queue responses.
- `match_joined` / `match_start` / `match_tick` / `match_end` — race lifecycle for players.
- `tavern_match_list` / `tavern_match_tick` / `tavern_match_end` — public race feed for tavern
- `game_mode_info` includes `modes.match { enabled, economies, maxPlayers, activeRuleset, rulesets }`.
- `match_queue_joined` and `match_queue_left` are queue responses.
- `match_joined`, `match_start`, `match_tick`, `match_end` are the race lifecycle for players.
- `match_rejoined` restores a reconnecting player; `match_settlement_pending` reports a result whose
payout is still settling.
- `player_forfeit` and `player_death` are in-match participant updates.
- `match_error` is the benign catch-all failure response for any match request.
- `tavern_match_list`, `tavern_match_tick`, `tavern_match_end` are the public race feed for tavern
spectators.
Client sends:
- `match_queue` `{ economy, action: 'join' | 'leave' }`
- `match_move` `{ dx, dy }`
- `match_leave` — forfeit an active race.
- `tavern_match_list` — request active races.
- `match_reconnect` to rejoin an active or just-finished match after a reconnect.
- `match_leave` to forfeit an active race.
- `tavern_match_list` to request active races.
Identity for every match request is resolved from the connection, never from the payload.
## Leaderboards
- **Free competitive matches** create synthetic `games` rows with `game_mode = 'FREE'` for the
Pleb board.
- **Credits/prestige competitive matches** are served from authoritative `matches` and
- Free competitive matches create synthetic `games` rows with `game_mode = 'FREE'` for the Pleb
board.
- Credits/prestige competitive matches are served from the authoritative `matches` and
`match_entrants` rows by `GET /api/leaderboard?board=prestige`.
- **Crypto competitive matches** create synthetic `games` rows with `game_mode = 'PAID_CREDITS'`
for the generic self-hosted Hall of Champions board. Such Software's two operated profiles keep
this economy disabled and exclude all match-generated rows from Champions.
- Crypto competitive matches create synthetic `games` rows with `game_mode = 'PAID_CREDITS'` for the
generic self-hosted Hall of Champions board. The operated product profiles
(`such-play-wow-prestige`, `such-monerogue-stagenet`) keep this economy disabled and exclude all
match-generated rows from Champions.
The public leaderboard endpoint requires `board=pleb|champions|prestige`; omitting `board` safely
defaults to `pleb`. The removed mixed `board=all` behavior and unknown or blank values return 400
instead of silently combining or translating economies.
The public leaderboard endpoint accepts `board=pleb|champions|prestige`. Omitting `board` defaults to
`pleb`; unknown or blank values return 400 rather than silently combining or translating economies.
An individual win requires the durable match winner and placement #1 to agree; reaching the exit
alone is never treated as a win in last-alive or score-attack. `coop-escape` is collective, records
no individual `winnerId`, and keeps its results in `matches` / `match_entrants`; it is intentionally
excluded from individual boards until a dedicated team leaderboard exists.
no individual `winnerId`, and keeps its results in `matches` and `match_entrants`. It is excluded
from individual boards.
## Tests
Run the match-mode test suites in isolation:
```bash
cd src
npx jest ../test/match
npx jest test/match
```
The suite includes block-deadline, authoritative leaderboard-result, co-op exclusion, durable
finish, and finished-room reconnect regressions in addition to the core room/queue/payout tests.
## Files added / modified
New:
- `src/migrations/022_match_mode.sql`
- `src/multiplayer/MatchRoom.js`, `MatchEngine.js`, `MatchState.js`
- `src/network/matchQueue.js`, `matchScheduler.js`, `matchManager.js`, `matchPayoutService.js`,
`matchLeaderboard.js`, `tavernMatchBridge.js`
- `html/match.html`, `html/js/matchClient.js`
- `docs/MATCH_MODE.md`, `src/.env.match.example`
- 7 test files
Modified:
- `src/network/socketHandlers.js` — initializes match services and wires socket events.
- `src/network/identityService.js` — adds `userForId()`.
- `src/game/gameModeManager.js` — advertises `modes.match`; grants race-entry tickets on product
payment.
- `src/payments/productGrants.js` — recognizes `grants.race_entries`.
- `src/index.js` — `/api/leaderboard?board=prestige`.
- `html/tavern.html` — race queue / spectator UI.
- `src/.env.example`, `docs/TAVERN_AND_MULTIPLAYER.md`, `docs/TODO.md`, `README.md`.
The suites cover the room, queue, scheduler, payout service, and leaderboard, plus block-deadline,
authoritative leaderboard-result, co-op exclusion, durable finish, crash recovery, client
settlement, and finished-room reconnect regressions.
## Operational notes
- Match mode is **opt-in** and inert unless `MATCH_ENABLED=true`.
- `MATCH_RULESET_ID` is trusted server configuration; unknown/solo-only values fall back to `race`.
- `MATCH_RULESET_ID` is trusted server configuration. Unknown or solo-only values fall back to
`race`.
- Crypto race payouts reuse the existing `payouts` table, batch processor, and retry service.
- `MATCH_PAYOUTS_ENABLED=false` stops new admission and refunds queued ticket escrow. Matches whose
pot/liability was already accepted still create their durable payout; the global payout master
switch may pause dispatch without erasing it.
- `MATCH_PAYOUTS_ENABLED=false` blocks new crypto admission. Queue entries persisted for a
now-unavailable economy are cancelled and refunded transactionally during queue initialization.
Matches whose pot and liability were already accepted still create their durable payout from the
immutable admission snapshot; the global payout master switch may pause dispatch without erasing
it.
- The unconditional unique index `idx_payouts_one_per_match` prevents a replacement payout in every
status, including `failed` and `needs_review`.
- Only confirmed-payment ticket lots with the current exact entry value can enter a crypto race;
legacy/admin-granted aggregate tickets cannot create a payout liability. The exact lot is held
- Only confirmed-payment ticket lots with the current exact entry value can enter a crypto race, so
legacy or admin-granted aggregate tickets cannot create a payout liability. The exact lot is held
on queue join and consumed in the same transaction that accepts the immutable match liability.
- Players who disconnect during a race have a 30-second grace period to reconnect.
- Paid freeze/start block events are serialized. A crash before activation leaves a resumable
`pending` freeze; a crash after activation is handled by abandoned-match recovery. Orderly
shutdown stops the scheduler, waits for its block task, and transactionally cancels/refunds every
- Players who disconnect during a race have a 30-second grace period to reconnect before an AFK kill.
Match mappings are retained for a further 30 seconds after a finish so late reconnects still
resolve the result.
- Paid freeze and start block events are serialized. A crash before activation leaves a resumable
`pending` freeze; a crash after activation is handled by abandoned-match recovery. Orderly shutdown
stops the scheduler, waits for its block task, and transactionally cancels and refunds every
still-pending freeze before shutting down match transport.
## See also
- [TAVERN_AND_MULTIPLAYER.md](TAVERN_AND_MULTIPLAYER.md)
- [RULESETS.md](RULESETS.md)
- [PRODUCTION_DISCLOSURES.md](PRODUCTION_DISCLOSURES.md)
+137 -76
View File
@@ -1,105 +1,166 @@
# Monetization — credits, catalog & entitlements (P1)
# Monetization: credits, catalog, and entitlements
One unit of account (**credits**), one operator-owned **catalog**, and a tiered **entitlement** rule that
decides who has unlocked which cosmetics — and now doubles as the premium-subscription payoff.
Cosmetics are sold through three pieces: **credits** (the unit of account), an operator-owned
**catalog** of packs, and an **entitlement rule** that decides which packs a given user has unlocked.
Payouts are a separate system and are not affected by any of this.
## Credits
Credits are the unit of account. A direct/single-game entry is modeled as *buy 1 credit + spend 1 credit*
(`gameModeManager.recordDirectEntryPurchase`) so it advances `total_credits_purchased` and unlocks the same
threshold cosmetics as a real credit purchase. Payouts are orthogonal to credits.
Two columns on `users` drive everything:
| Column | Meaning |
|---|---|
| `credits` | spendable balance; decremented when a game is entered with credits |
| `total_credits_purchased` | lifetime credits purchased, cumulative, never decremented by play |
Credit packages are declared in `src/config/paymentConfig.js` under `modes.credits.packages`
(`{ id, credits, price, bonus }`). On confirmation, `gameModeManager.processProductPaymentConfirmation`
adds `credits + bonus` to both columns in one transaction and writes a `credit_transactions` row.
A direct (single-game) entry is modeled as *buy one credit, immediately spend it*
(`gameModeManager.recordDirectEntryPurchase`): the balance nets to zero, `total_credits_purchased`
advances by 1, and two `credit_transactions` rows (`direct_entry` purchase, `game_entry` spend)
record the pair. A direct payment therefore unlocks the same threshold cosmetics as a credit purchase.
Refunds reverse what a payment granted. `src/services/paymentRefundService.js` deducts the granted
credits, purchase progression, and race entries, restores the previous `premium_level`, and revokes
pack grants. If the grant cannot be reversed exactly (credits already spent, progression already
consumed, ambiguous provenance on a bare direct entry), the refund is parked as `needs_review`
instead of leaving a cosmetic unlock behind.
## The catalog
`cosmetic_catalog` (migration `024`) is the operator-owned product table — it replaces the old hardcoded,
split-brain `PACKS` (server + client). Columns: `pack_id, label, kind, projection, tier, unlock_min_credits,
grant_only, sort_order, active, metadata`.
`cosmetic_catalog` (migration `024`, repriced by migration `028`) is the operator-owned product
table and the single server-authoritative source for pack definitions:
`src/services/catalogService.js` loads + caches it, falling back to `DEFAULT_CATALOG` if the table is
missing (42P01) or empty — so the game runs before the migration and never hard-fails on catalog reads.
The server ships `catalogSummary()` to the client, which merges it into `RK.PACKS` — the client no longer
owns a parallel catalog.
```
pack_id, label, kind, projection, tier, unlock_min_credits,
grant_only, sort_order, active, metadata
```
`src/services/catalogService.js` loads active rows ordered by `sort_order`, caches them for 60
seconds, and exposes `invalidate()`. It falls back to the built-in `DEFAULT_CATALOG` in
`src/multiplayer/entitlements.js` when the table is missing (`42P01`), empty, or yields no valid
rows, so the game runs on a fresh or partly-migrated database and never hard-fails on a catalog read.
Rows without both `pack_id` and `label` are skipped.
`catalogSummary()` projects the catalog into the display-facing shape the client renders
(`id, label, kind, projection, tier, premium, unlockMinCredits`).
## The unlock rule
`src/multiplayer/entitlements.js` — `snapshotForUser(user, grants, catalog)`. A pack unlocks if **any** of:
`snapshotForUser(user, grants, catalog)` in `src/multiplayer/entitlements.js`. A pack is unlocked if
**any** of:
1. it is free: `tier === 0`, `unlockMinCredits == null`, and not `grantOnly`
2. an explicit grant exists in `user_pack_entitlements` (unexpired)
3. `total_credits_purchased >= unlock_min_credits` (when the pack sets a threshold)
4. the user's premium tier is `>=` the pack's `tier` (when `tier > 0`)
`TIER_OF = { free: 0, credits: 0, supporter: 1, premium: 2, operator: 3 }`. Clause 4 is the
subscription hook. Buying credits maps to tier 0, so a purchase never blanket-unlocks the premium
tiers; it only advances the threshold in clause 3.
The returned snapshot is `{ premium, level, tier, credits, totalCreditsPurchased, packs, catalog }`,
where `packs` is a per-pack boolean map and `premium` is true when the user has any premium tier or
any non-free pack unlocked.
## The credit ladder
Clause 3 keys off lifetime credits *purchased*, cumulatively. It is not deducted by play, so buying
credits to play also walks the user up the cosmetic ladder and cosmetics never compete with the core
loop. Each catalog row is data: a tilepack or a character skin, at any rung and any tier. Adding a
pack means appending a catalog row, shipping the assets, and calling `RK.registerPack` on the client.
| Lifetime credits | Pack | Projection | Tier |
|---|---|---|---|
| free | `original` (bare original tiles) | topdown | 0 |
| 1 | `roguelike-interior` | topdown | 1 |
| 5 | `generated-skins` (character skins) | topdown | 1 |
| 10 | `iso-dungeon` | iso | 2 |
| 20 | `roguelike-dungeon` | topdown | 2 |
| 40 | `iso-medieval` | iso | 3 |
| 50 | `kenney-3d-characters` | 3d | 3 |
The `original` pack maps only the essentials (floor, wall, door, torch, features); furniture kinds
fall back to floor, so the free tavern reads deliberately plain.
Because the tier column is also live, a `premium` subscription (tier 2) unlocks every pack at tier
2 or below without any credit spend; tier 3 packs need `operator` or the credit threshold.
### Render techniques versus packs
`RK.RENDER_MODES` in `html/js/render/renderModes.js` defines four techniques: Tiled and ASCII are
free, Iso and 3D are premium. `RK.canUseMode` grants a premium technique once the user has **any**
unlocked pack for its projection, so the cheapest pack in a projection is effectively that
technique's gate. Plain ASCII stays free for accessibility.
Client-side gating is a cosmetic honour gate, not a security boundary: rendering happens in the
browser. The server-side boundary is `Entitlements.normalizeAppearance`, which resets an appearance
whose avatar pack the user has not unlocked before it is persisted or broadcast.
## Subscription tiers
`levelForUser(user)` returns the higher of the stored `premium_level` and any `subscription_tier`,
so an active subscription sets the entitlement tier and unlocks the packs at or below it. One
subscription drives both chat perks and cosmetics; the chat side is documented in
[CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md).
`identityService.entitlementsForUser` populates `subscription_tier` before taking the snapshot by
calling `SubscriptionService.tierForNpub(user.smirk_public_key)`. The call is best-effort: any
resolver failure leaves the field unset and the snapshot falls back to credit thresholds and grants.
```
1. it's free (tier 0, no gate)
2. an explicit grant (products → grants)
3. lifetime credits PURCHASED ≥ the pack's unlock_min_credits threshold
4. the user's premium TIER ≥ the pack's tier
active subscription -> tierForNpub(npub) -> user.subscription_tier -> levelForUser -> tier
|- relay: chat perks
|- catalog: pack unlocks
```
`TIER_OF = { free:0, credits:0, supporter:1, premium:2, operator:3 }`. Clause 4 is the premium hook.
**Buying credits does NOT put you on the premium ladder** (`credits` → tier 0) — that was the old bug where
any purchase unlocked every premium pack.
`src/services/subscriptionService.js` resolves a tier from two optional sources, in order, and caches
the result for 5 minutes. With neither configured it returns `null` and no one is auto-promoted.
### The ladder (loyalty model)
Clause 3 keys off **lifetime credits _purchased_**, cumulatively — it is **not deducted**, so buying credits
to *play* also walks you up the cosmetic ladder (cosmetics never compete with the core loop). Each catalog
row is just data — a **tilepack OR a char-skin**, at any credit rung — so the operator adds more of either by
appending a row (+ assets + `registerPack` for a client pack). This IS the "infinite packs" system.
| Rung (lifetime credits) | Unlocks |
| Environment variable | Meaning |
|---|---|
| **free** | `original` bare tiles (no furniture → the "ugly tavern", by design) · plain ASCII |
| 1 | `roguelike-interior` (first premium tilepack) |
| 5 | `generated-skins` (character skins) |
| 10 | `iso-dungeon` — unlocks the **Iso** technique |
| 20 | `roguelike-dungeon` (another tilepack) |
| 40 | `iso-medieval` (second iso pack) |
| 50 | `kenney-3d-characters` — unlocks **3D** |
| `PREMIUM_NPUBS` | operator allowlist, `npub1…\|64hex[:tier],…`; npub entries are decoded to x-only hex to match `users.smirk_public_key` |
| `PREMIUM_DEFAULT_TIER` | tier for allowlist entries with no explicit `:tier` (default `premium`) |
| `SMIRK_PREMIUM_STATUS_URL` | optional HTTP endpoint answering premium-by-npub with `{ active, tier? }` |
| `SMIRK_PREMIUM_STATUS_KEY` | bearer token sent to that endpoint |
A render **technique** (Iso/3D) is available once the user has *any* unlocked pack for its projection, so the
cheapest pack in a projection is effectively that technique's gate. Plain ASCII stays free (accessibility).
Source of truth: `cosmetic_catalog` table (migration 028) mirrored by `DEFAULT_CATALOG` in `entitlements.js`.
The Smirk backend's `/premium/status` is self-only (user-token auth), so it cannot answer a lookup
for an arbitrary npub. The allowlist covers that case with no backend dependency; the HTTP seam is
for a service-scoped endpoint where one exists.
## Premium subscription → cosmetics
## Reaching the client
`levelForUser(user)` returns the **higher** of the stored `premium_level` and an active
`subscription_tier`. So a live **wowne.ro premium subscription** sets the entitlement tier, which unlocks
tile/customization packs at/below it — **one subscription drives both chat perks and cosmetics** (see
[CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md)).
The server emits `identity_update` with `{ appearance, entitlements }`, where `entitlements` carries
the `packs` map and the projected `catalog`. `RK.setEntitlementSnapshot` in
`html/js/render/assetPacks.js` merges the served catalog into `RK.PACKS` (merge, not replace, so a
client-side fallback a render mode depends on survives a catalog that omits it), recomputes the
per-pack unlock map, and persists the snapshot to `localStorage` for the next load. Operator-added
packs appear on the client without a client release.
Everything downstream — pack gating, the served catalog, the client unlock UI — already consumes `tier`.
The data source is `src/services/subscriptionService.js` (`SubscriptionService.tierForNpub`), which
`identityService.entitlementsForUser` calls to populate `user.subscription_tier` before the snapshot:
`?unlock=1` sets a sticky per-browser QA bypass (`RK.renderModeTestUnlocks`) that unlocks premium
modes and packs locally; `?unlock=0` clears it. It affects only the browser that sets it.
```
active premium sub ──► SubscriptionService.tierForNpub(npub) ──► user.subscription_tier ──► levelForUser ──► tier
├─ relay: chat perks (badge, no-PoW, VIP)
└─ catalog: tile / skin / customization unlocks
```
## Operated product profiles
Two sources, checked in order (both optional — absent ⇒ tier null ⇒ unchanged legacy behavior):
| Env | Meaning |
|-----|---------|
| `PREMIUM_NPUBS` | operator allowlist `npub1…\|hex[:tier],…` — works **today**, zero backend dependency |
| `PREMIUM_DEFAULT_TIER` | tier for allowlist entries without an explicit `:tier` (default `premium`) |
| `SMIRK_PREMIUM_STATUS_URL` / `_KEY` | optional HTTP endpoint answering premium-by-npub `{active, tier?}`, for full automation |
> The Smirk backend's `/premium/status` is **self-only** (user-token auth), so the game can't look up
> an arbitrary npub through it. Full automation needs a small backend addition — a service/by-npub
> premium endpoint (or a webhook that sets the tier). Until then, `PREMIUM_NPUBS` is the working source.
When `OPERATED_PRODUCT_PROFILE` is set, `src/config/operatedProductProfiles.js` rejects standalone
cosmetic products (`products.cosmetic`) and any credit package carrying a `grants` object. Those
deployments sell only the credits and bonus declared at the top level of a credit package, so the
credit ladder and subscription tiers are the only unlock paths. Independent deployments do not set
the variable and are not constrained by it.
## Files
```
src/migrations/024_cosmetic_catalog.sql operator-owned catalog table
src/services/catalogService.js load/cache + DEFAULT_CATALOG fallback
src/multiplayer/entitlements.js snapshotForUser · levelForUser · TIER_OF · unlock rule
src/services/subscriptionService.js premium tier by npub (PREMIUM_NPUBS allowlist + HTTP seam)
src/network/identityService.js entitlementsForUser (loads catalog + grants + sub tier)
src/game/gameModeManager.js recordDirectEntryPurchase (direct entry = 1 credit)
src/migrations/024_cosmetic_catalog.sql catalog table
src/migrations/028_cosmetic_ladder.sql ladder rows (idempotent upsert)
src/services/catalogService.js load, cache, DEFAULT_CATALOG fallback
src/multiplayer/entitlements.js snapshotForUser, levelForUser, TIER_OF, unlock rule
src/services/subscriptionService.js premium tier by npub
src/services/paymentRefundService.js entitlement reversal and needs-review routing
src/network/identityService.js entitlementsForUser (catalog + grants + subscription tier)
src/game/gameModeManager.js recordDirectEntryPurchase, credit package confirmation
html/js/render/assetPacks.js client consumes the served catalog
html/js/render/renderModes.js technique registry and gating
```
## Open decisions
- Premium tiers: one flat "premium," or a ladder (supporter/premium/operator) where each rung unlocks
progressively more cosmetics *and* chat perks? (`TIER_OF` already has the rungs.)
- Full-automation source: add a service/by-npub premium endpoint to the Smirk backend (then set
`SMIRK_PREMIUM_STATUS_URL`) vs. a webhook that stamps the tier. `PREMIUM_NPUBS` covers it manually today.
+73 -33
View File
@@ -1,48 +1,88 @@
# 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).
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`](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 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.
- 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.
## Provably fair across the descent
## Provable fairness 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)`.
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:
```js
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 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.
`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 + 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.
| 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` |
+142 -56
View File
@@ -1,85 +1,171 @@
# Payments (P3)
# Payments
The game no longer implements per-chain wallet/daemon code inline. It talks to pluggable **payment
The game does not implement per-chain wallet or daemon code inline. It talks to pluggable **payment
providers** behind one contract, so an operator routes each chain to whichever backend they run.
## The contract
## Provider contract
`src/payments/providers/paymentProvider.js`:
`src/payments/providers/paymentProvider.js` defines the interface. All amounts are atomic units for
the chain (BigInt or an integer-valued string), never decimal floats.
```
createInvoice({chain, amountAtomic, description, userId, orderId})
→ {invoiceId, address, uri, amountAtomic, expiresAt, raw}
getInvoiceStatus(ref) → {status, complete, paidAtomic, confirmations, raw}
-> {invoiceId, address, uri, amountAtomic, expiresAt, raw}
getInvoiceStatus(ref)
-> {status: 'pending'|'processing'|'paid'|'expired'|'invalid', complete, paidAtomic, confirmations, raw}
startWatch(ref, onUpdate, intervalMs) / stopWatch(ref)
sendPayout({chain, address, amountAtomic}) → {txids, raw}
validateAddress(chain, address) → {valid}
supportsChain(chain) → bool
sendPayout({chain, address, amountAtomic}) -> {txids, raw} (optional)
validateAddress(chain, address) -> {valid, reason} (optional)
supportsChain(chain) -> boolean
```
`PaymentProviderRegistry` routes `chain → providerId` (operator-configurable) with a support-based
fallback: if the routed provider can't serve a chain, any registered provider that *can* is used.
`PaymentProviderRegistry` maps `chain -> providerId` and is operator-configurable. `getProvider(chain)`
returns the routed provider; if that provider does not support the chain, the registry falls back to
any registered provider that does, and finally to the default provider.
## The providers
## Providers
| Provider | id | Serves | Notes |
|----------|-----|--------|-------|
| **BTCPay Greenfield** | `btcpay` | BTC, LTC | `src/payments/providers/btcpayProvider.js` — real BTCPay Server. Invoice priced in the chain's own crypto; address read from the invoice payment-methods; `token` auth. |
| **xmrcheckout** | `xmrcheckout` | XMR | Same class, different endpoint — the checkout app exposes a **BTCPay-Greenfield-compatible** shim. |
| **wowcheckout** | `wowcheckout` | WOW | Ditto (matched pair to xmrcheckout). |
| **Native Monero** | `native-monero` | XMR, WOW | `src/payments/providers/nativeMoneroProvider.js` — wraps the existing `walletRPCService` (subaddress invoicing + `transfer_split` payouts). |
| Provider | id | Serves | Implementation |
|----------|-----|--------|----------------|
| BTCPay Greenfield | `btcpay` | BTC, LTC by default | `src/payments/providers/btcpayProvider.js`, a BTCPay Server client |
| xmrcheckout | `xmrcheckout` | XMR | Same class against a checkout app that exposes Greenfield-compatible routes |
| wowcheckout | `wowcheckout` | WOW | Same class, matched pair to xmrcheckout |
| Native Monero/Wownero | `native-monero` | XMR, WOW | `src/payments/providers/nativeMoneroProvider.js`, wraps `walletRPCService` |
The key discovery: **BTCPay, xmrcheckout, and wowcheckout all speak one Greenfield contract**, so a single
`BTCPayProvider` class (constructor-configurable `id`) serves all three — just registered at different
endpoints. See the `btcpay-infra-topology` note for the operator's LAN endpoints.
BTCPay, xmrcheckout, and wowcheckout all speak the same Greenfield contract, so one `BTCPayProvider`
class with a constructor-configurable `id` serves all three; they differ only in base URL, store id,
API key, and chain set. Auth on all three is `Authorization: token <apiKey>`. Invoices are priced in
the chain's own crypto by passing the chain ticker as the Greenfield `currency`, and the receiving
address is read from the invoice's payment-methods endpoint.
## Bootstrap & routing
The native provider keys invoices by their Monero/Wownero subaddress and returns the wallet's
`addressIndex`, which is what lets monitoring be restored after a restart.
`buildProviderRegistry({env, walletService})` (`src/payments/providers/index.js`) is the single wiring seam:
## Bootstrap and routing
- Native Monero/Wownero provider registers whenever a `walletService` exists.
- Each Greenfield gateway activates from its own env trio:
`BTCPAY_URL/_STORE_ID/_API_KEY` (chains from `BTCPAY_CHAINS`, default BTC,LTC),
`XMRCHECKOUT_URL/_STORE_ID/_API_KEY` (XMR), `WOWCHECKOUT_URL/_STORE_ID/_API_KEY` (WOW).
- Default routing: BTC/LTC → btcpay, XMR/WOW → their checkout gateway (else native). `PAYMENT_ROUTING`
(`CHAIN:provider,…` or JSON) overrides any mapping.
`buildProviderRegistry({env, walletService})` in `src/payments/providers/index.js` is the single
wiring seam:
**Behavior-preserving:** with no gateway env set (prod today), only the native provider registers and
*every* chain routes to it — byte-for-byte the legacy flow.
- The native provider is registered whenever a `walletService` is supplied, and claims XMR and WOW.
- Each Greenfield gateway activates from its own environment trio:
`BTCPAY_URL` / `BTCPAY_STORE_ID` / `BTCPAY_API_KEY` (chain list from `BTCPAY_CHAINS`, default
`BTC,LTC`), `XMRCHECKOUT_URL` / `_STORE_ID` / `_API_KEY` (XMR), and
`WOWCHECKOUT_URL` / `_STORE_ID` / `_API_KEY` (WOW).
- A dedicated checkout gateway wins over native for its own chain.
- `PAYMENT_ROUTING` overrides every mapping. It accepts JSON (`{"BTC":"btcpay","XMR":"native-monero"}`)
or a compact `CHAIN:provider,CHAIN:provider` string; unparseable values are ignored.
- The default provider, used for chains with no explicit route, is the native provider when present,
otherwise the first registered gateway.
## The hot path
With no gateway environment set, only the native provider is registered and every chain routes to it,
which is the single-chain wallet-RPC flow. `GameModeManager` builds this registry in its constructor
unless one is injected.
- **Invoice creation** — `gameModeManager.createPaymentRequest` calls `getProvider(cryptoType).createInvoice(...)`.
The native provider delegates to the same `walletService.createPaymentRequest`, so the subaddress and
monitoring maps are identical to before.
- **Monitoring** — `network/paymentHandlers._monitorAddress` calls `provider.startWatch`. Native passes the
raw wallet status straight through (legacy path unchanged); `BTCPayProvider.getWalletStatus` maps a
Greenfield invoice to the same raw shape (`Settled → confirmed+complete`, `Processing → in_mempool`,
paid/required amounts from payment-methods via `ChainProfile` decimals). The confirmation logic is
extracted to `_handlePaymentStatus`, shared by native + gateway.
- **Payouts** stay native (the checkout apps are receive-only; XMR/WOW payouts remain on wallet-RPC).
The general environment template is `src/.env.example`. The gateway variables above are read straight
from the process environment; add them to the deploy host's environment when enabling a gateway.
## Turning a gateway on
## Invoice lifecycle
1. Mint a store API key in the gateway's admin (money infra — a human step).
2. Set the `*_URL/_STORE_ID/_API_KEY` env on the deploy host, using an address the host can actually reach
(verify LAN reachability; a nebula/overlay IP may not route).
3. Restart. Invoices for that chain now create + confirm through the gateway; no code redeploy.
**Creation.** `gameModeManager.createPaymentRequest` resolves the provider for `CRYPTO_TYPE` and calls
`createInvoice`. Any stale `pending` payments for the user are expired first, so a user holds at most
one active invoice. The row is inserted into `payments` with `provider_id`, `provider_invoice_id`,
`address_index`, the expected atomic amount, product grants, and the fairness commitment fields.
Invoices expire at the provider-supplied `expiresAt`, or 30 minutes from creation when the provider
does not supply one.
## Money math
**Monitoring.** `network/paymentHandlers._monitorAddress` records a socket-to-payment mapping, arms a
30 minute expiry timer, and calls `provider.startWatch(watchRef, onStatus, 2000)`. The watch reference
is the subaddress for the native provider and the invoice id for a gateway. `onStatus` receives a raw
wallet-style status (`in_mempool`, `confirmed`, `complete`, `amount`, `required`, `confirmations`,
`receipts`): the native provider passes the wallet's own status through untouched, and
`BTCPayProvider.getWalletStatus` maps a Greenfield invoice into the same shape (`Settled` and
`Complete` become confirmed and complete, `Processing` becomes in-mempool, paid and required amounts
come from the payment-methods response converted through `ChainProfile` decimals). Both feed the one
handler, `_handlePaymentStatus`.
All amounts are **atomic BigInt**, decimals-parameterized via `src/money/atomic.js` (`format`, `add`,
`mulByDecimal`, …) and `src/chain/chainProfile.js` (`decimalsFor`, `atomicDivisor`). Never floats. WOW = 11
decimals, XMR/BTC/LTC per their chain, GRIN = 9.
**Confirmation.** A mempool sighting queues a single-game entry or announces a pending purchase; a
confirmed status applies the entitlement. Underpayment emits `payment_underpaid` and leaves the
invoice open so the balance can be topped up to the same address. Confirmation is idempotent: an
in-memory set is only a fast path, and the database status transition under `FOR UPDATE` is the source
of truth, so a restart cannot double-apply. When paid fairness binding is required, an entry whose
invoice carries no bound fairness proof is refused at both the queue and confirm steps and reported as
`payment_review_required` rather than started.
## Files
**Receipt evidence.** Every confirmation must be backed by durable receipts in `payment_receipts`
(migration `037_payment_receipt_evidence.sql`). Native receipts are `chain_output` rows identified by
`txHash:outputId` and carrying the invoice's `address_index`; gateway receipts are `provider_invoice`
rows identified by the invoice id. A unique index on `(provider_id, evidence_id)` makes each piece of
evidence consumable exactly once, and a database trigger rejects any transition into `confirmed` whose
confirmed receipts do not cover `expected_amount`. This is why one transaction with several outputs,
or one invoice topped up by several transactions, both settle correctly.
**Late payments.** `src/services/latePaymentReconciler.js` scans expired invoices, re-reads status
through the owning provider (`getWalletStatus` for gateways, wallet RPC for native), and records
durable evidence in `payment_late_reviews` for manual adjudication. A late receipt never grants an
entitlement on its own. See [FINANCIAL_RECONCILIATION.md](FINANCIAL_RECONCILIATION.md).
## Payouts
Payouts run through the native wallet service, not through the routed provider: the checkout gateways
are receive-only. `gameModeManager` sends winnings via `walletService.processPayout` for single
payouts and `processBatchPayout` for batches, both of which use `transfer_split` and return
`tx_hash_list`. `src/payments/payoutRetryService.js` retries failures. `NativeMoneroProvider`
implements `sendPayout` so the contract is complete, and it delegates to the same batch call.
Payout address validation prefers `walletService.validateAddress`, which is network-aware
(mainnet/stagenet/testnet), and falls back to the provider's `validateAddress` only when the wallet
service exposes none.
Payout behaviour is gated by `PAYOUTS_ENABLED` as a master switch with subordinate per-mode flags
(`DIRECT_PAYOUTS_ENABLED`, `CREDITS_PAYOUTS_ENABLED`, `MATCH_PAYOUTS_ENABLED`). See
[PRODUCTION_DISCLOSURES.md](PRODUCTION_DISCLOSURES.md).
## Money and chain parameters
All amounts are atomic integers. Arithmetic goes through `src/money/atomic.js` (`toBig`, `toSafe`,
`sum`, `add`, `mulByDecimal`, `format`), and decimals come from `src/chain/chainProfile.js`
(`decimalsFor`, `atomicDivisor`). No floating-point money anywhere.
| Chain | Decimals | Mean block time | Family | URI scheme |
|-------|----------|-----------------|--------|------------|
| WOW | 11 | 300000 ms | monero | `wownero` |
| XMR | 12 | 120000 ms | monero | `monero` |
| BTC | 8 | 600000 ms | utxo | `bitcoin` |
| LTC | 8 | 150000 ms | utxo | `litecoin` |
| GRIN | 9 | 60000 ms | mimblewimble | `grin` |
An unknown or unset chain resolves to an XMR-shaped default (12 decimals, 120000 ms, monero family)
so a mis-set `CRYPTO_TYPE` degrades instead of crashing. `meanBlockTimeMsFor` is what block-time-aware
difficulty scales against; per-chain dungeon sizing lives in `NETWORK_TUNING` in
`src/game/difficultyConfig.js`.
Payment QR codes are generated by `src/payments/qrService.js` as `monero:` or `wownero:` URIs with
`tx_amount` and a truncated `tx_description`. It returns `null` when the optional `qrcode` package is
absent, and the address and amount are always shown as text as well.
## Enabling a gateway
1. Mint a store API key in the gateway's own admin interface.
2. Set that gateway's `*_URL`, `*_STORE_ID`, and `*_API_KEY` on the deploy host, using an address the
host can actually reach. Verify reachability from the host itself; an overlay-network address may
not route from the service account.
3. Restart the service. Invoices for that chain are created and confirmed through the gateway with no
code change.
## File map
```
src/payments/providers/paymentProvider.js contract + registry
src/payments/providers/paymentProvider.js contract + registry
src/payments/providers/btcpayProvider.js Greenfield client (btcpay / xmrcheckout / wowcheckout)
src/payments/providers/nativeMoneroProvider.js wraps walletRPCService
src/payments/providers/index.js buildProviderRegistry (env → registry)
src/payments/walletRPCService.js Monero/Wownero wallet-RPC
src/game/gameModeManager.js createPaymentRequest (invoice seam)
src/network/paymentHandlers.js _monitorAddress / _handlePaymentStatus (monitoring seam)
src/payments/providers/index.js buildProviderRegistry (env -> registry)
src/payments/walletRPCService.js Monero/Wownero wallet RPC, payouts
src/payments/payoutRetryService.js payout retry worker
src/payments/qrService.js payment-URI QR codes
src/chain/chainProfile.js decimals, block times, families, URI schemes
src/money/atomic.js atomic-integer money arithmetic
src/game/gameModeManager.js invoice creation, confirmation, receipts, payouts
src/network/paymentHandlers.js monitoring and status handling
src/services/latePaymentReconciler.js post-expiry evidence and review records
src/migrations/037_payment_receipt_evidence.sql receipts, late reviews, confirmation-evidence trigger
```
+111 -64
View File
@@ -1,33 +1,50 @@
# Production disclosures and paid-action gate
This implementation makes the running server describe its actual free, paid-prestige, payout,
test-network, and PvP switches. It does **not** claim that the product is licensed or lawful in a
particular place. Obtain jurisdiction-specific advice before accepting assets with real value.
The server derives its public disclosures from its own runtime switches, so the words shown to a
player describe the free, paid-prestige, payout, test-network, and PvP configuration that instance
actually runs. Nothing here asserts that a deployment is licensed or lawful in a particular place.
Obtain jurisdiction-specific advice before accepting assets with real value.
Implementation: `src/config/commerceDisclosurePolicy.js` (disclosure generation and acknowledgement
validation), `src/config/operatedProductProfiles.js` (operated-product contracts),
`src/config/environmentValidator.js` (startup and preflight checks),
`html/js/legal/commerceConsent.js` (browser acknowledgement), `src/views/legalPages.js`
(`/terms`, `/privacy`, `/responsible-play`).
## Operated-product boundary
Such Software (`apps@such.software`) operates only:
Such Software (`apps@such.software`) operates two public services and nothing else.
| Profile | Public service | Exact scope |
|---|---|---|
| `such-play-wow-prestige` | `play.wowne.ro` | Wownero mainnet free play and pay-for-credits leaderboard/prestige. Credits are non-redeemable; there are no prizes, payouts, cash-out, or crypto racing, and the service is not offered/marketed as gambling. Legal classification depends on applicable law. |
| `such-monerogue-stagenet` | `monerogue.app` | Monero stagenet direct-entry solo only. Single-player 2×/3× test gambling mechanics use test coins with **NO REAL VALUE**. Purchased-credit entry and crypto-match payouts are off. Never mainnet XMR. |
| Profile | Service | Policy version | Scope |
|---|---|---|---|
| `such-play-wow-prestige` | `play.wowne.ro` | `2026-07-22-v1`, effective `2026-07-22` | Wownero mainnet free play plus pay-for-credits leaderboard and prestige play. Credits are non-redeemable service entitlements. No prizes, payouts, cash-out, or crypto racing, and the service is not offered or marketed as gambling. Legal classification depends on applicable law. |
| `such-monerogue-stagenet` | `monerogue.app` | `2026-07-23-v2`, effective `2026-07-23` | Monero stagenet direct-entry solo play only. The single-player 2x and 3x outcomes are test gambling mechanics using test coins with **NO REAL VALUE**. Purchased-credit entry and crypto-match payouts are off. Never mainnet XMR. |
The corresponding `.env.mainnet.example` and `.env.stagenet.example` files opt into a fail-closed
startup contract with `OPERATED_PRODUCT_PROFILE`. Preflight and normal startup reject any drift in
host, operator identity, chain/network, paid modes, multipliers, or payout gates. Independent MIT
self-hosts leave this setting unset and configure a truthful operator identity of their own.
The operated Wownero disclosure is version `2026-07-22-v1`; Monerogue's direct-only scope is a
material change and therefore uses version `2026-07-23-v2`, effective 2026-07-23.
Setting `OPERATED_PRODUCT_PROFILE` to one of those identifiers opts a deployment into a fail-closed
startup contract. Preflight (`npm run preflight`) and normal startup reject any drift from the
profile in:
- `HOSTED_BY` public root, `OPERATOR_NAME`, `OPERATOR_CONTACT_URL`, `OPERATOR_CONTACT_LABEL`;
- `LEGAL_POLICY_VERSION` and `TERMS_EFFECTIVE_DATE`;
- `CRYPTO_TYPE`, `MONERO_NETWORK`, `GAME_MODE`, and `PAYMENT_MODES`;
- direct/credits mode switches, every payout switch, and `ALLOW_MAINNET_PAYOUTS`;
- the stagenet product's exact `DIRECT_PAYOUT_ESCAPE=2` and `DIRECT_PAYOUT_TREASURE=3` multipliers;
- the product catalog: no standalone products, and a credit package may grant only its top-level
credits and bonus. The whole `grants` object is rejected, including keys added later, so a new
entitlement cannot silently widen the public product contract.
Independent MIT self-hosts leave `OPERATED_PRODUCT_PROFILE` unset and configure a truthful operator
identity of their own; none of the profile constraints apply to them. Reference environments are
`src/.env.mainnet.example` and `src/.env.stagenet.example`; the full annotated template is
`src/.env.example`.
## Open-source and operator disclaimer
MIT permission to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies is
subject to retaining the copyright and permission notice in all copies or substantial portions.
The software is provided “AS IS”, without warranty of any kind, as stated in `LICENSE`. The MIT
License governs rights in the source. Documentation, examples, and disclosure copy are
informational only; they are not legal advice or a determination that a deployment complies with
applicable law.
MIT permission to use, copy, modify, merge, publish, distribute, sublicense, and sell copies is
subject to retaining the copyright and permission notice in all copies or substantial portions. The
software is provided "AS IS", without warranty of any kind, as stated in `LICENSE`. The MIT License
governs rights in the source. Documentation, examples, and disclosure copy are informational only;
they are not legal advice or a determination that a deployment complies with applicable law.
A third-party or self-hosted operator is solely responsible for its deployment, product design,
legal compliance, funds, players, representations, and support. The MIT License does not make that
@@ -37,85 +54,115 @@ third-party deployments.
## Required production settings
Every production instance with `PAYMENTS_ENABLED=true` fails preflight/startup unless all of these
are explicit and valid:
With `NODE_ENV=production` and payments resolved as enabled, startup and preflight fail unless each
of these is explicit and valid:
```dotenv
OPERATOR_NAME=Truthful responsible operator name
OPERATOR_CONTACT_URL=mailto:support@example.com
OPERATOR_CONTACT_LABEL=Support
LEGAL_POLICY_VERSION=2026-07-22-v1
TERMS_EFFECTIVE_DATE=2026-07-22
MINIMUM_AGE=18
PAID_ACKNOWLEDGEMENT_REQUIRED=true
```
Production match mode also requires explicit paid-fairness timing, even if only prestige credits
are presently exposed:
Constraints: `OPERATOR_NAME` is 2 to 120 characters and may not contain placeholder text;
`OPERATOR_CONTACT_URL` must parse as an `https:` or `mailto:` URL without placeholder text;
`LEGAL_POLICY_VERSION` is 1 to 64 characters of `[A-Za-z0-9._-]` starting alphanumeric;
`TERMS_EFFECTIVE_DATE` is a real `YYYY-MM-DD` date; `MINIMUM_AGE` is an integer from 18 through 120.
`OPERATOR_CONTACT_LABEL` is optional. Without it the disclosure derives a label from the contact
URL. An operated product profile does require its exact configured value.
`RESTRICTED_LOCATIONS_NOTICE` is optional application copy. Add it only after legal review; text is
not geofencing.
Changing the terms or any material disclosure requires a new `LEGAL_POLICY_VERSION`. That
invalidates every prior browser-session acknowledgement.
### Match fairness timing
Production with `MATCH_ENABLED=true` also requires explicit paid-fairness timing, even when only
prestige credits are exposed:
```dotenv
MATCH_PAID_ENTROPY_DELAY_BLOCKS=2
MATCH_PAID_ENTROPY_CONFIRMATIONS=2
```
Both are bounded safe integers from 2 through 100. Paid PvP persists the actual delay,
confirmation requirement, activation threshold, and post-commit daemon-tip witness in its public
proof; changing these settings does not reinterpret an already-frozen match.
`RESTRICTED_LOCATIONS_NOTICE` is optional application copy. Add it only after legal review; text is
not geofencing. Changing the terms or material disclosures requires a new `LEGAL_POLICY_VERSION`,
which invalidates prior browser-session acknowledgements.
Both are safe integers from 2 through 100. A paid match freezes its entry set, commits to a target
block `DELAY_BLOCKS` ahead, and derives its seed only from a header confirmed to the configured
depth. The persisted proof records the derivation, the confirmation requirement, the minimum
confirmed tip height, and the pre-commit daemon-tip witness with its timestamp, so changing these
settings later cannot reinterpret an already-frozen match.
## Enforcement contract
- `/api/disclosures` returns non-secret, no-store policy data derived from runtime switches.
- `/api/disclosures` returns non-secret, `no-store` policy data derived from runtime switches.
- `/terms`, `/privacy`, and `/responsible-play` render the active mode and operator metadata.
- Before a payment invoice, paid solo/credit entry, or paid PvP queue join, the browser requires
unselected age/reading/risk boxes. Test networks require an additional mainnet/test-coin box.
- Acceptance is a five-field canonical record (policy version plus four booleans), scoped to the
current browser tab and policy version, and cleared on rejection or socket disconnect.
- The server validates the exact current version and booleans before consuming a fairness offer,
opening an invoice, consuming a credit, or escrowing a paid PvP entry. A modified client cannot
bypass this check.
- Free entry remains available without a paid acknowledgement where the instance offers it.
- Before a payment invoice, paid solo or credit entry, or a paid PvP queue join, the browser
presents unchecked age, terms-read, and risk boxes and enables the continue button only when all
are selected. Test networks add a mainnet/test-coin box.
- Acceptance is a five-field canonical record: `policyVersion` plus `ageEligible`, `termsRead`,
`riskAccepted`, and `testnetUnderstood`. It lives in `sessionStorage`, so it is scoped to the
browser tab and to the exact policy version, and it is cleared on cancel, on a version mismatch,
and on socket disconnect.
- The server independently validates the exact current version and every boolean before consuming a
fairness offer, opening an invoice, consuming a credit, or escrowing a paid PvP entry. The check
runs at each of those handlers, so a modified client cannot bypass it.
- The paid-action requirement does not lapse when invoice intake is paused: already-purchased
credits and race tickets still carry value and are still gated.
- Free entry stays available without a paid acknowledgement where the instance offers it. Early
entry is treated as free only on a free-only instance, because mixed and credits instances may
consume a credit.
The acknowledgement is a player statement, not proof of identity, age, capacity, or location.
The acknowledgement is a player statement. It does not verify identity, age, capacity, or location.
## Mode language
| Runtime mode | Player-facing contract |
|---|---|
| Free | No entry payment; free leaderboard; no free payout |
| Paid, payouts off | Entry/credits/product purchase and paid prestige leaderboard; no crypto prize |
| Paid, solo payouts on | Full entry can be lost; only pre-entry recorded outcomes/amounts qualify |
| Crypto PvP on | Paid ticket escrow and configured winner/fee contract; separate from free/prestige PvP |
| Paid, payouts off | Entry, credits, or product purchase and paid prestige leaderboard; no cryptocurrency prize |
| Paid, solo payouts on | The full entry can be lost; only outcomes and amounts shown before entry qualify |
| Crypto PvP on | Paid ticket escrow and the configured winner/fee contract; separate from free and prestige PvP |
| XMR stagenet/testnet | Valueless test currency; explicit warning never to send mainnet XMR |
For the operated products, the generic rows narrow further: `play.wowne.ro` exposes purchased
credits only, offers no payout/cash-out, and is not marketed as gambling; `monerogue.app` labels
its direct-entry 2×/3× stagenet mechanics as **NO REAL VALUE** and keeps purchased-credit entry and
For the operated products the generic rows narrow further: `play.wowne.ro` exposes purchased credits
only, offers no payout or cash-out, and is not marketed as gambling; `monerogue.app` labels its
direct-entry 2x and 3x stagenet mechanics as **NO REAL VALUE** and keeps purchased-credit entry and
crypto-match payouts disabled.
## Unresolved launch decisions
## Operator decisions before enabling real-value modes
Technical copy and checkboxes do not resolve these. Keep real-value modes gated until the responsible
operator records decisions for:
Disclosure copy and checkboxes are product safety controls, not a substitute for the following. A
responsible operator records decisions on each before exposing a real-value mode:
1. entity/operator identity, licences/registrations, permitted and excluded locations;
1. entity and operator identity, licences and registrations, permitted and excluded locations;
2. age and location verification appropriate to those locations;
3. consumer terms: refunds, late/under/overpayments, complaints, dispute venue, and downtime;
4. privacy roles, processors, retention/deletion schedule, user-rights procedure, breach response,
and whether a privacy-minimized durable acknowledgement record is legally required;
5. responsible-play features such as account-level deposit/time/loss limits and durable self-exclusion;
6. tax, sanctions/AML, accounting, and cryptocurrency reporting obligations;
7. an independent review of game rules, advertised multipliers, house fee, bankroll, and provable-fair claims.
3. consumer terms: refunds, late, under, and overpayments, complaints, dispute venue, and downtime;
4. privacy roles, processors, retention and deletion schedule, user-rights procedure, breach
response, and whether a privacy-minimized durable acknowledgement record is legally required;
5. responsible-play features such as account-level deposit, time, and loss limits, and durable
self-exclusion;
6. tax, sanctions and AML, accounting, and cryptocurrency reporting obligations;
7. an independent review of game rules, advertised multipliers, house fee, bankroll, and
provable-fair claims.
Stagenet can be used for end-to-end mechanics without representing test coins as money. Mainnet
payouts remain a separate, explicit operational and legal decision.
Stagenet exercises end-to-end mechanics without representing test coins as money. Mainnet payouts
are a separate, explicit operational and legal decision.
## Front-end dependency note
## Front-end dependency notes
The same-origin jQuery copy is 3.7.1 from the Ubuntu `libjs-jquery` package; exact provenance and
SHA-256 are in `html/js/lib/jquery-3.7.1.PROVENANCE.md`. The CSP still permits inline script/style
because current pages depend on them. A nonce migration must move every inline handler/script before
removing `unsafe-inline`; do not partially tighten it and strand payment/game controls.
SHA-256 are in `html/js/lib/jquery-3.7.1.PROVENANCE.md`. No runtime CDN or package-registry request
is needed to serve it.
Third-party renderer code executes with the same privileges as the game and payment UI, so CDN
execution is off unless an operator sets `RENDERER_CDN_ENABLED=true`. Enabling it adds
`cdn.jsdelivr.net` to `script-src` and `connect-src` and logs a warning in production. The
Three.js build served from `/vendor/three/<version>` is same-origin.
The CSP permits inline script and style because current pages depend on them. A nonce migration has
to move every inline handler and script first; a partial tightening removes payment and game
controls from the page.
-58
View File
@@ -1,58 +0,0 @@
# `play.wowne.ro` production state — 2026-07-25
> This is a dated evidence record, not a deployment procedure or authorization to repeat an
> exceptional release path. The normal gates in [DEPLOY.md](DEPLOY.md),
> [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md), and [RELEASE_ARTIFACT.md](RELEASE_ARTIFACT.md) remain
> controlling.
## Active schema-neutral release
`play.wowne.ro` is serving the immutable release
`20260725T121348Z-28d513-overlay-a9d6f8a`. It was prepared from exact predecessor
`20260721T143902Z-28d513b29b36` and carries feature source marker
`a9d6f8a1bd1af7e77f7872629e325452ed5ae46a`.
The overlay changes only browser/static renderer and Tavern behavior plus the same-origin
Three.js runtime:
- single-player Tiled, ASCII, Iso, 3D, pack, and camera controls return keyboard focus to the game;
- Tavern entry is explicitly anonymous, waits for session readiness, exposes pending/error states,
and remains retryable;
- Three.js `0.160.0` is loaded from versioned same-origin vendor routes;
- production CDN renderer execution remains off;
- the existing Wownero mainnet purchased-credit profile remains unchanged, with every payout path
off.
The live database migration ledger still ends at
`038_economic_identity_immutability.sql`. The overlay neither applied nor marked migrations
039–043 and did not adopt their payment/accounting behavior.
## Verification evidence
- Complete source suite: 123 suites passed, 979 tests passed.
- Local and public readiness probes passed with PostgreSQL, Wownero daemon, and wallet dependencies
ready.
- External HTTPS and Socket.IO/WebSocket smoke tests passed through the public domain.
- A fresh browser anonymously entered the Tavern and rendered local Three.js plus the generated GLB
model from same-origin resources.
- Single-player mode/pack clicks retained exact `#game-display` focus; a live 3D run rendered WebGL,
attached the animated model, and removed its temporary fallback.
- Activation rollback was exercised by failed smoke gates before the corrected release passed.
Operator-host evidence:
| Record | SHA-256 |
|---|---|
| Release manifest | `8ffd5077d906c7307094eda9f1c2332fd7d1edf1bf3b337807dc8c8e5a54b780` |
| Activation receipt | `be9ee6c8657222e11464a6e4aff6aebede067e54e83d5d70aa1cf2ac7fa5b437` |
## Full mainline cutover remains STOP-SHIP
This production state does **not** mean the complete mainline runtime artifact has been activated.
Mainline contains migrations through 043; the existing Wownerogue database remains five migrations
behind. A full cutover must not proceed until the reviewed fleet runbook's clone-validation,
daemon/wallet, accounting, drain, activation-receipt, and rollback gates all pass for the exact
candidate and predecessor.
The schema-neutral overlay is not precedent for bypassing those gates. Future activation or
rollback must use the reviewed fleet control plane with literal targets, hashes, and receipts.
+102 -68
View File
@@ -1,29 +1,33 @@
# Deterministic PvP capture harness
`src/scripts/pvp-capture.js` creates a clean, reproducible multiplayer match for trailers, ads,
`src/scripts/pvp-capture.js` produces a clean, reproducible multiplayer match for trailers, ads,
camera work, and gameplay review. It supports the built-in Escape Race, Last Alive, Score Attack,
and Co-op Escape rulesets. Each take uses the authoritative `MatchRoom` and `MatchEngine`, drives
deterministic bots through the installed Socket.IO client, and records the existing browser render
kit with the installed Playwright Chromium.
and Co-op Escape rulesets. Each take runs the authoritative `MatchRoom` and `MatchEngine`, drives
deterministic bots over Socket.IO, and records the browser render kit with the locally installed
Playwright Chromium.
This is intentionally not a production feature or an admin endpoint. The harness:
This is a development tool, not a production feature or an admin endpoint. The harness:
- starts a separate HTTP/Socket.IO process bound to `127.0.0.1` on an ephemeral port;
- accepts no remote target URL and cannot attach to a normal Wowngeon instance;
- creates one in-memory `free` match with zero entry fee, pot, house fee, or payout;
- imports no application, database, wallet, queue, provider, or payout service;
- requires `NODE_ENV=development` or `NODE_ENV=test` and the explicit
- starts a separate HTTP/Socket.IO process bound to `127.0.0.1` on an ephemeral port, and rejects
any request whose `Host` header is not loopback;
- accepts no remote target URL, so it cannot attach to a running Wowngeon instance;
- creates one in-memory `free` match with zero entry fee, pot, house fee, and house fee percent,
and re-checks that invariant at room creation, at match start, and after finalization;
- imports no application, database, wallet, queue, provider, or payout service. Its only game
imports are `MatchRoom`, `MatchEngine`, and the ruleset registry;
- requires `NODE_ENV=development` or `NODE_ENV=test` plus the explicit
`--confirm-local-free-only` flag;
- refuses to run when a payment, credits, payout, crypto-race, mainnet-payout, or wallet endpoint
is enabled/configured; and
- refuses to run when a payments, credits, payout, match-payout, crypto-race, mainnet-payout, or
wallet-endpoint variable is enabled or configured;
- refuses inherited crypto, game-mode, difficulty, dungeon, monster, treasure, or network-tuning
overrides so a committed seed cannot silently mean different footage in another shell; and
- loads only repository assets. CDN/3D renderers are unavailable in this tool.
overrides, so a committed seed cannot silently mean different footage in another shell;
- serves only repository assets and forces the CDN renderer flag off, so the 3D renderer is
unavailable here.
Those guards make setting an environment variable in the live service insufficient to enable the
harness. Someone must deliberately start this separate loopback-only CLI with its confirmation
flag. On a staging machine, run it as a separate development/test process; it never uses the
staging app or staging database.
Setting an environment variable on a live service is therefore not enough to enable the harness.
Someone must deliberately start this separate loopback-only CLI with its confirmation flag. On a
staging machine, run it as its own development or test process; it never uses the staging app or
staging database.
## Record a video
@@ -39,10 +43,9 @@ NODE_ENV=development npm run capture:pvp -- \
--screenshot /tmp/wowngeon-pvp-final.png
```
The repository already declares `playwright-core`; the command uses its existing local Chromium
and FFmpeg bundle. It never downloads a browser or package. If that browser bundle is missing, the
CLI fails with a direct explanation. The deterministic control harness remains usable without a
browser:
`playwright-core` is a declared devDependency. The command uses its existing local Chromium and
FFmpeg bundle and never downloads a browser or a package. If that bundle is missing, the CLI stops
with a direct explanation. The deterministic control harness remains usable without a browser:
```bash
NODE_ENV=test npm run capture:pvp -- \
@@ -52,34 +55,61 @@ NODE_ENV=test npm run capture:pvp -- \
--trace /tmp/wowngeon-pvp-trace.json
```
Run `npm run capture:pvp -- --help` for every option. `--ruleset` accepts `race`, `last-alive`,
`score-attack`, or `coop-escape`; this is separate from `--mode`, whose offline renderer choices
are `tiles`, `ascii`, and `iso`. Every ruleset accepts 2–8 bots in this harness.
## Options
The default 1280×720 spectator camera anchors on `bot-1`; choose another player with `--focus
bot-N`. In the default `--camera action` mode, deaths, treasure pickups, and exits temporarily take
focus before the camera returns to that anchor. `--camera focus` disables those cuts. Camera zoom
in the tiled and ASCII renderers adjusts to the nearest action cluster around the anchor: it keeps
the closest three players in a portrait composition or four in landscape, then pushes in or pulls
back as that cluster changes. The isometric renderer uses a stable, cover-clamped zoom because its
diagonal projection needs different fit geometry. A short renderer-space movement lead shows where
the focused player is heading, while an upper-frame bias keeps portrait action clear of the
result lower third. The tested 1080×1920 and 1920×1080 tiled frames fill the stage. At match end, a
competitive camera returns to the authoritative winner for a restrained push-in instead of hiding
the playfield behind a centered modal.
Run `npm run capture:pvp -- --help` for the same list at the terminal.
The capture-only grade slightly lowers brightness while restoring contrast and saturation, which
reduces torch and character washout. A separate screen-space roster
keeps all 2–8 player names and states legible without stacking labels on one dungeon tile; the
winner remains highlighted while other rows recede on a competitive final frame, while cooperative
team rows remain equally visible. The footer displays a short deterministic replay ID, not a
signature or independent verification claim. The complete seed and every state hash remain in the
| Flag | Accepted values | Default |
|---|---|---|
| `--confirm-local-free-only` | required safety acknowledgement | none |
| `--seed HEX64` | exactly 64 hexadecimal characters | fixed curated seed |
| `--players N` | 2 through 8, within the ruleset's own limit | 4 |
| `--ticks N` | 1 through 2000 hard capture limit | 180 |
| `--tick-ms N` | 50 through 1000 real-time frame step | 180 |
| `--ruleset ID` | `race`, `last-alive`, `score-attack`, `coop-escape` | `race` |
| `--mode MODE` | `tiles`, `ascii`, `iso` | `tiles` |
| `--focus BOT_ID` | `bot-1` through `bot-N` for the configured bot count | `bot-1` |
| `--camera MODE` | `action` or `focus` | `action` |
| `--viewport WxH` | 640x360 through 3840x2160 | 1280x720 |
| `--output FILE.webm` | video destination | `wowngeon-pvp-capture.webm` in the temp dir |
| `--trace FILE.json` | trace destination | the output path with a `.json` extension |
| `--screenshot FILE.png` | optional final-frame screenshot | none |
| `--headed` | show the capture browser while recording | off |
| `--no-video` | run bot control and write only the trace | off |
`--ruleset` selects gameplay and is independent of `--mode`, which selects an offline renderer.
Network-loaded renderers are rejected. All four capture rulesets accept 2 through 8 bots here: the
harness raises Score Attack's own minimum of 1 to 2, because a capture needs a field.
## Camera and presentation
The spectator camera anchors on `--focus`, default `bot-1`. In `--camera action`, deaths, treasure
pickups, and exits briefly take focus before the camera returns to that anchor; `--camera focus`
disables those cuts.
In the tiled and ASCII renderers, zoom tracks the action cluster around the anchor: it fits the
closest three players in a portrait composition or four in landscape, then pushes in or pulls back
as that cluster changes. The isometric renderer uses a stable, cover-clamped zoom because its
diagonal projection needs different fit geometry. Every zoom is clamped to at least stage cover, so
the playfield fills the frame at both 1080x1920 and 1920x1080. A short renderer-space movement lead
shows where the focused player is heading, and an upper-frame bias keeps portrait action clear of
the result card in the lower third. At match end, a competitive camera returns to the authoritative
winner for a restrained push-in rather than covering the playfield with a centered modal.
The capture-only grade lowers brightness slightly while restoring contrast and saturation, which
reduces torch and character washout. A screen-space roster keeps all 2 to 8 player names and states
legible without stacking labels on one dungeon tile; world-space player labels are suppressed for
the same reason. On a competitive final frame the winner row stays highlighted while other rows
recede; cooperative rulesets have no winner row, so team rows remain equally visible. The footer
shows a short deterministic replay ID derived from the seed hash. It is an identifier, not a
signature or an independent verification claim. The complete seed and every state hash live in the
JSON trace.
`FREE EXHIBITION • NO CASH PRIZES` describes this isolated footage only. It is not a substitute for
campaign review or campaign-level disclosures. Paid-credit, no-cash-out/no-prize, age, jurisdiction,
certification, and platform-required disclosures remain outside the harness output and must be
added and reviewed for each operated product and advertising channel.
campaign review or campaign-level disclosures. Paid-credit, no-cash-out, no-prize, age,
jurisdiction, certification, and platform-required disclosures sit outside the harness output and
must be added and reviewed for each operated product and advertising channel. See
[PRODUCTION_DISCLOSURES.md](PRODUCTION_DISCLOSURES.md).
## Capture each built-in mode
@@ -89,8 +119,8 @@ Use the same safety acknowledgement for every take:
|---|---|---|
| `race` | Treasure route plus staggered sprints to the exit | Competitive race winner |
| `last-alive` | Seek and strike the nearest active rival | Last survivor |
| `score-attack` | Build progress/treasure score, then bank an exit | Top score and points |
| `coop-escape` | Staggered team pathing to the exit | Team escape count, never a fake competitive winner |
| `score-attack` | Build progress and treasure score, then bank an exit | Top score and points |
| `coop-escape` | Staggered team pathing to the exit | Team escape count, never a competitive winner |
For example, record a Last Alive take:
@@ -105,13 +135,15 @@ NODE_ENV=development npm run capture:pvp -- \
--trace /tmp/wowngeon-last-alive-ad.json
```
Swap only `--ruleset` and output names to capture Score Attack or Co-op Escape. Headlines, active
player terminology, roster states, footer copy, and final results all follow the selected ruleset.
Score Attack closes the local exhibition deadline as soon as every bot has died or banked an exit,
avoiding a motionless tail without changing the authoritative scoring/finalization path.
Swap only `--ruleset` and the output names to capture Score Attack or Co-op Escape. Headlines,
active-player terminology, roster states, footer copy, and final results all follow the selected
ruleset. Score Attack normally runs to its deadline; once every bot is dead or has banked an exit
there are no legal inputs left, so this isolated exhibition closes its local deadline immediately
instead of recording a long motionless tail. The authoritative scoring and finalization path is
unchanged.
For a short 9:16 ad take, the curated seed produces a complete six-racer arc in about nine seconds.
Follow the eventual winner so the finish remains legible on a phone screen:
For a short 9:16 ad take, the default seed with six racers produces a complete arc in roughly nine
seconds and `bot-4` wins. Follow that bot so the finish stays legible on a phone screen:
```bash
NODE_ENV=development npm run capture:pvp -- \
@@ -124,10 +156,7 @@ NODE_ENV=development npm run capture:pvp -- \
--screenshot /tmp/wowngeon-pvp-ad-vertical.png
```
Use the emitted JSON trace as the authoritative replay record. The video is marketing footage; it
does not create a leaderboard row, credit, payment, ticket, or payout.
For a crowded 16:9 arena take, the same seed supports the built-in eight-player maximum:
The same seed supports the eight-player maximum for a crowded 16:9 arena take:
```bash
NODE_ENV=development npm run capture:pvp -- \
@@ -141,8 +170,8 @@ NODE_ENV=development npm run capture:pvp -- \
--trace /tmp/wowngeon-pvp-ad-8player.json
```
The recorded eight-player proof and its no-video control both ended with `bot-4` at tick 49 and
the same final state hash. This checks that browser recording does not alter the authoritative race.
The emitted JSON trace is the authoritative replay record. The video is marketing footage; it
creates no leaderboard row, credit, payment, ticket, or payout.
## Reproducibility
@@ -155,13 +184,18 @@ NODE_ENV=development npm run capture:pvp -- \
```
Each bot receives a numbered tick and the same authoritative state over Socket.IO. The server waits
for every bot response, queues inputs in stable bot order, and lets the engine resolve them using its
committed per-tick action priority before advancing. Bot goals are ruleset-aware but contain no
randomness beyond the committed dungeon seed. Missing or malformed input aborts the run instead of
silently changing the replay. Every run writes a versioned JSON trace beside the video containing
the ruleset, presentation metadata, seed, exact inputs, events, camera configuration, state hashes,
placements, scores, and mode-correct outcome.
for every bot response, queues inputs in stable bot order, and lets the engine resolve them with its
committed per-tick action priority before advancing. Bot goals are ruleset-aware and contain no
randomness beyond the committed dungeon seed; bots after the first move on staggered cadences so a
crowded field stays readable and deterministic head-on swaps cannot loop forever. Missing or
malformed input aborts the run instead of silently changing the replay.
The gameplay trace is deterministic, and records that gameplay environment overrides were refused.
Encoded WebM bytes can differ across Chromium/FFmpeg versions
or host timing, so use the JSON state hashes—not the video checksum—to compare replays.
Every run writes a versioned JSON trace, owner-readable only, beside the video. It records the
ruleset, presentation metadata, seed and seed hash, exact per-tick inputs, engine events, camera
configuration, per-tick state hashes, placements, scores, the mode-correct outcome, and the fact
that gameplay environment overrides were refused.
To confirm that browser recording does not perturb the simulation, run the same command twice, once
with `--no-video`, and compare `result.finalStateHash` in the two traces. Encoded WebM bytes can
differ across Chromium and FFmpeg versions or with host timing, so compare replays with the JSON
state hashes rather than a video checksum.
+105 -62
View File
@@ -1,52 +1,76 @@
# Immutable release artifacts
`scripts/deploy/build-release-artifact.sh` is the local, non-activating boundary between a reviewed
Git commit and the two production application instances. It produces a **runtime artifact**, not a
source or operations bundle. It does not read an environment file, install a package, connect to
`deb`, or move either `current` symlink.
Git commit and a production application instance. It produces a runtime artifact, not a source or
operations bundle. It does not read an environment file, install a package, contact a deployment
host, or move a `current` symlink.
## Artifact contents
The archive holds a single top-level directory named after the release ID. That directory contains
`LICENSE`, `html/**`, the runtime subset of `src/**`, and release metadata.
Everything else in the commit is excluded on purpose: `docs/`, `test/`, the root `scripts/` tree
(service units, firewall and backup jobs, the hash-pinned financial audit SQL, the wallet output
splitter), environment examples, `src/sim`, `src/jest.config.js`, and the operator-only commands
`src/scripts/pvp-capture.js`, `src/scripts/stagenet-financial-canary.js`, `src/scripts/setup_db.js`,
and `src/scripts/smoke.js`. Tests and operator tooling are source and control-plane inputs; they are
not production runtime files, and sharing a Git commit with the runtime does not make them part of
it.
`src/scripts/preflight.js` and `src/scripts/migrate-disposable-clone.js` do ship, because the staging
gates run them from the candidate. The staged `src/package.json` is rewritten to expose only the
runnable `start`, `preflight`, and `db:migrate:clone` scripts. That rewrite is metadata-only and
changes neither dependencies nor lockfile resolution.
Release metadata:
| File | Covers |
| --- | --- |
| `.release-id` | Release directory name |
| `.release-commit` | Full 40-character source commit |
| `.release-source-sha256` | The prefix-free, runtime-allowlisted `git archive` tar |
| `.release-package-lock-sha256` | The shipped `src/package-lock.json` |
| `.release-files.sha256` | Every extracted runtime and metadata file |
| `<release-id>.tar.gz.sha256` (external sidecar) | The final compressed blob |
`.release-files.sha256` is generated before any dependency install, so it covers the shipped bytes
and never covers `src/node_modules`.
Builds are reproducible: for the same commit, release ID, and toolchain the `.tar.gz` is
byte-identical, because tar entries are sorted by name, ownership is numeric `0:0`, modes are
normalized, mtimes are pinned to the commit timestamp, and gzip runs with `-n`. The artifact and its
sidecar are published by hard link, so a concurrent build using the same release ID fails rather than
overwriting a published file. Both land mode 0600 in a mode-0700 output directory.
## Build gate
Commit every intended source and documentation change first. Review that commit and confirm that a
secret scanner has not found credentials embedded in otherwise legitimate source files; filename
policy alone cannot detect a hard-coded secret. Then run:
policy alone cannot detect a hard-coded secret.
```bash
git status --short # must print nothing
./scripts/deploy/build-release-artifact.sh # defaults to git-<12-character HEAD prefix>
cd dist/releases
sha256sum --check <release-id>.tar.gz.sha256
```
The builder fails on any tracked or non-ignored untracked worktree change, tracked symlink/gitlink,
runtime `.env` or credential-capable dotfile, unsafe release ID, or existing output. Ignored local
files are never read because the archive comes only from the selected commit. The archive contains
only `LICENSE`, `html/**`, the runtime subset of `src/**`, and deterministic release metadata.
It intentionally contains no `docs/`, `test/`, root `scripts/`, deployment service units, wallet
migration tools, environment examples, ad-capture bot, stagenet transfer canary, or development
database/smoke helpers. Simulation/Jest sources are also excluded, and the staged package manifest
retains only the runnable `start`, `preflight`, and `db:migrate:clone` commands. The guarded
`src/scripts/preflight.js` and
`src/scripts/migrate-disposable-clone.js` remain available for staging checks.
`.release-source-sha256` identifies the prefix-free,
runtime-allowlisted `git archive`; the external sidecar identifies the final compressed artifact;
`.release-files.sha256` verifies every extracted runtime/metadata file without covering the later
`src/node_modules` install.
Run the complete test suite from the clean source commit **before** building. Tests and operator
tools are source/control-plane inputs; they are not production runtime files:
Run the complete test suite from the clean source commit before building:
```bash
cd src
npm ci --ignore-scripts --no-audit --no-fund
npm test
cd ..
git status --short # must still print nothing
./scripts/deploy/build-release-artifact.sh
git status --short # must print nothing
./scripts/deploy/build-release-artifact.sh # defaults to git-<12-character HEAD prefix>
cd dist/releases
sha256sum --check <release-id>.tar.gz.sha256
```
Then test the runtime bytes that will be shipped. In a disposable directory, verify the artifact
sidecar and manifest and install only the locked production dependency graph:
The builder refuses to run on any tracked or non-ignored untracked worktree change, a tracked
symlink, gitlink, or unsafe file mode, a filename containing a newline or carriage return, an unsafe
tracked path, tracked `.git` or `node_modules` state, a tracked non-example `.env` file, a tracked
`.npmrc`, `.yarnrc`, or `.netrc`, an unsafe release ID, or an existing artifact or checksum. Ignored
local files are never read, because the archive is produced from the selected commit rather than the
worktree.
Then test the runtime bytes that will ship. In a disposable directory, verify the sidecar and
manifest and install only the locked production dependency graph:
```bash
cd <directory-containing-artifact>
@@ -59,8 +83,20 @@ npm ci --omit=dev --ignore-scripts --no-audit --no-fund
npm ls --omit=dev
```
Advisory lookup is a separate, explicitly authorized network operation. Do not use `npm audit fix`
to mutate a candidate or live release.
Advisory lookup is a separate, explicitly authorized network operation. Do not use `npm audit fix` to
mutate a candidate or a live release.
## Release identity at runtime
`src/config/releaseIdentity.js` reads `.release-id` and `.release-commit` from the release root at
startup. Each must be a non-symlink regular file with a link count of 1, no write bits, and a size
between 1 and 128 bytes, and it is re-stat-ed after reading to reject a file that changed mid-read.
The two values must agree: the ID must be `git-<first 12 characters of the commit>`. Under
`NODE_ENV=production` the process refuses to start when that check fails, so an artifact built with a
custom `--release-id` will not run in production even though the builder accepts the name.
`/health`, `/health/ready`, and the status disclosure report the verified `release.id` and
`release.commit`. That is how activation confirms which bytes are actually serving traffic.
## Production staging gate
@@ -68,46 +104,53 @@ Use the same runtime artifact SHA-256 for `monerogue` and `wownerogue`. Transfer
root-controlled staging path and verify the operator-pinned 64-hex digest before extraction. The
activation boundary must then:
1. reject an existing release ID, links/special files, an unexpected top-level directory, or unsafe
ownership/path components;
1. reject an existing release ID, links or special files, an unexpected top-level directory, or
unsafe ownership or path components;
2. install only locked production dependencies with
`npm ci --omit=dev --ignore-scripts --no-audit --no-fund` while staging is writable;
`npm ci --omit=dev --ignore-scripts --no-audit --no-fund` while staging is still writable;
3. run `npm ls --omit=dev`, recheck `.release-files.sha256`, then make the complete release
`root:root` and non-writable before it can become `current`;
4. restore each fresh pre-deploy dump into a separately named clone database, run
`npm run db:migrate:clone` under the exact disposable-target contract in
[CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md), require its exact manifest proof (currently through
migration 043), and run the separately reviewed and hash-pinned fleet copy of
`financial-audit.sql` there—the SQL is intentionally not inside the runtime artifact;
5. run `npm run preflight` from the candidate with each final protected environment. Preflight is
intentionally non-mutating and does not replace database, daemon, or wallet health checks.
`npm run db:migrate:clone` under the disposable-target contract in
[CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md), and require its exact manifest proof: the
`schema_migrations` ledger must equal the candidate's complete migration manifest, in order, with
no missing or extra entry, contiguous from 001 and at or above the runner's floor of migration
043. Run the separately reviewed and hash-pinned fleet copy of `financial-audit.sql` against that
clone; the SQL is intentionally not inside the runtime artifact;
5. run `npm run preflight` from the candidate against each final protected environment. Preflight
validates payment and environment configuration only. It connects to neither PostgreSQL nor either
RPC, so it does not replace database, daemon, or wallet health checks.
The clone runner is deliberately non-activating. Such Software production activation must use the
separately reviewed, default-closed `wowngeon_release_activate` role in `~/src/such-fleet`; do not
substitute manual extraction, dependency installation, service commands, or a hand-written
`current` switch. The role still refuses activation until the matching clone-validation receipt,
wallet-promotion receipt, drain/audit gates, literal hashes, and rollback target satisfy the
complete contract in `~/src/such-fleet/RUNBOOK-wowngeon.md`.
The clone runner is deliberately non-activating. Production activation uses the separately reviewed,
default-closed `wowngeon_release_activate` role in the fleet control-plane repository. Do not
substitute manual extraction, dependency installation, service commands, or a hand-written `current`
switch. That role refuses activation until the matching clone-validation receipt, wallet-promotion
receipt, drain and audit gates, literal hashes, and rollback target all satisfy the contract in the
fleet runbook `RUNBOOK-wowngeon.md`.
## Source and operations boundary
The Git checkout remains the review/test source. `~/src/such-fleet` is the deployment control plane
for hash-pinned SQL, service units, backup/firewall jobs, clone validation, activation, and rollback.
Neither source-tree deployment scripts nor fleet tools are copied beneath the application release
directory. A runtime artifact hash does not attest a separately delivered operations file; record
and verify an independent hash for every such file in the fleet change plan.
The Git checkout is the review and test source. The fleet control-plane repository owns hash-pinned
SQL, service units, backup and firewall jobs, clone validation, activation, and rollback. Neither
source-tree deployment scripts nor fleet tools are copied beneath the application release directory.
A runtime artifact hash attests nothing about a separately delivered operations file; record and
verify an independent hash for every such file in the fleet change plan.
## Activation and rollback gate
Drain public ingress and require zero active and queued games. Take and validate fresh dumps; run the
financial audit before and after stopping only the affected app. Preserve and validate the exact
predecessor target, switch `current` atomically, then start stagenet first. Require bounded local
`/health/live` and `/health/ready`, exact network/money-mode disclosures, public TLS, WebSocket, and
leaderboard/intake smoke tests before traffic is restored. The stagenet payout canary is a later,
one-shot gate and is never a mainnet command.
Drain public ingress and require zero active and queued games. Take and validate fresh dumps, and run
the financial audit before and after stopping only the affected app. Preserve and validate the exact
predecessor target, switch `current` atomically, then start the stagenet instance first.
Application rollback is safe only if the predecessor was tested against the *post-migration* clone
schema. Migrations are forward-only and run one transaction per file at application startup; a
Before traffic is restored, require bounded local `/health/live` and `/health/ready`, the expected
network and money-mode disclosures, public TLS, WebSocket, and leaderboard and intake smoke tests.
`/health/ready` answers 503 while any check is degraded and reports `chain.network`,
`money.paymentsEnabled`, `money.payoutsEnabled`, and the verified release identity, so the disclosure
comparison is exact rather than approximate. The stagenet payout canary is a later, one-shot gate and
is never a mainnet command.
Application rollback is safe only if the predecessor was tested against the post-migration clone
schema. Migrations are forward-only and run one transaction per file at application startup, so a
mid-sequence failure can leave earlier files committed. On activation failure, stop the candidate,
atomically restore the validated predecessor symlink, start it, and require both the exact target and
readiness. Do not automatically restore the database after new writes; keep the dump for an
-80
View File
@@ -1,80 +0,0 @@
# Wownerogue Remediation & Cleanup Plan
> Derived from the deep code review (May 2026). Goal: fix every BAD/UGLY finding, remove
> the spaghetti, and reach a **real-funds launch gate**. Phases are ordered so launch-blockers
> get surgical fixes first, then correctness hardening, then structural cleanup, then a
> test/ops overhaul to lock it in.
## Guiding principles
1. **Stabilize before restructuring.** Fix exploitable money bugs as minimal patches, ship, *then* refactor.
2. **Single source of truth** for the three things that bite: money (one BigInt module), identity (one `user_id`), randomness (one per-game seeded RNG).
3. **Defense in depth.** Every check at the DB *and* app level; every render escapes *and* the server escapes.
4. **Every change ships with a test that fails before and passes after.**
Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker · 🟠 pre-launch · 🟡 post-launch
---
## Phase 0 — Stop the bleeding (launch-blockers)
- [x] **0.1 🔴 S — Verify amount received ≥ expected before confirming.** ✅ Gate on `status.complete` in `paymentHandlers.js`; underpaid → stay pending + `payment_underpaid` warning (once). `received_amount` now recorded for single-game + credits. Regression test added (`payment_flow.integration.test.js`). _113 tests green._
- [x] **0.2 🔴 M — Make provably-fair real.** ✅ Per-game seeded RNG (`seedToInt`) threaded through `dungeon.js` (Digger reseed + all `Math.random()` → `rng`), `monster.js` (`seededShuffle`), `game.js` (chase + generation). `DungeonGenerator.regenerateFromSeed` + `layoutFingerprint`; `/api/verify` regenerates & returns fingerprint. Determinism test suite added (`provablyFair.test.js`). _Verifier can now reproduce the exact dungeon from the seed._
- [x] **0.3 🔴 M — Fix batch-payout `tx_hash` collision + double-pay.** ✅ Migration 015 drops the payouts `tx_hash` unique index (batches legitimately share a tx_hash) and extends the one-per-game guard to cover `processing`. Batch completion now happens in ONE transaction via `id = ANY` (no row stranded mid-batch); BIGINT stat math (no float). Ambiguous batch failures → `needs_review` (retry service won't re-send) + operator alert. New `batchPayout.test.js` covers both paths.
- [x] **0.4 🔴 S — Harden identity transport.** ✅ Client sends `anon_token` in handshake `auth` (not the URL query → no proxy/referer/access-log leakage); server reads `auth` first with a query-string fallback for older clients. Token is now **rotated on every resume** (`rotateToken`), so a leaked token is invalidated when the real owner reconnects; client already persists the returned token. Rotation + old-token-replay-rejection test added to `security.test.js`.
- [x] **0.5 🟠 S — Single-game disconnect recovery.** ✅ `recoverPendingPayments` now also recovers confirmed-but-unconsumed `single_game` payments (no `games.payment_id` link) by granting the equivalent credits, idempotently (per-payment reason key, row-locked). Disconnect preservation made deterministic: the stable `dbUserId` is **stamped on the game at creation**, so suspend/restore never needs a fragile disconnect-time DB lookup — every game (paid or free) is reconnectable whenever a session exists; the drop-branch is now only the genuine no-session edge. New `singleGameRecovery.test.js` (grant / consumed / idempotent).
- [x] **0.6 🟠 S — Client + admin XSS and CSP.** ✅ Single shared `escapeHtml` helper (`html/js/core/escapeHtml.js`, escapes all five chars incl. both quotes); leaderboard delegates to it. Chat sinks (`onChatBroadcast`/`onChatHistory`) now escape username + message. Admin panel escapes user-controlled fields (payout address, socket id, player name, status) and its local `escapeHtml` now escapes quotes (safe in `title="…"`). CSP + `X-Content-Type-Options`/`X-Frame-Options`/`Referrer-Policy` headers added via middleware in `index.js` (`connect-src 'self'` keeps Socket.IO working; nonce-based tightening tracked as Phase 4.4).
## Phase 1 — Money correctness hardening (🟠)
- [x] **1.1 M — `src/money/atomic.js` BigInt module.** ✅ Exact integer money math (`toBig`/`toSafe`/`sum`/`add`/`mulByDecimal`/`format`). `calculatePayout` now computes `base * multiplier` via BigInt (half-up rounding, narrows to number only when exact). `walletRPCService` batch total + fee sum use `money.sum` instead of float `reduce`. Property tests (`moneyAtomic.test.js`) prove exactness on amounts above 2^53 where the old float path drifted.
- [x] **1.2 S — Snapshot payout config at game start.** ✅ Migration 016 adds `payout_escape_amount`/`payout_treasure_amount` (BIGINT) + multipliers to `games`. Both start paths stamp the resolved payout amounts (via `_computePayoutSnapshot`); `completeGame` pays from the snapshot (falls back to live calc only for pre-snapshot games). New `payoutSnapshot.test.js` proves a mid-game config change can't alter an in-flight payout.
- [x] **1.3 S — Close payout uniqueness holes.** ✅ `processing` covered in the unique index (migration 015) and in the in-code existing-payout guard. Migration 017 adds idempotent `NOT VALID` CHECK constraints on `payments.status`, `payments.payment_type`, and `payouts.status` so a typo'd status can't silently orphan a money row (enforces new writes, won't choke on legacy rows). _(games.status/game_mode CHECKs deferred — transient states, lower money-impact.)_
- [x] **1.4 S — Fix retry stat guard + non-atomic tx_hash write.** ✅ The "already on-chain" path used a `NOT EXISTS` guard against the row it had just marked completed (always skipped → never counted); replaced with a conditional `UPDATE … WHERE status <> 'completed' RETURNING` so stats count exactly once. Successful retries now store `tx_hash` + mark completed + count stats in ONE transaction (no stranded-tx_hash window). New `payoutRetry.test.js` covers count-once, no-double-count, and atomic completion.
- [x] **1.5 S — Fix dead refund send-path.** ✅ The admin refund called a nonexistent `walletRPCService.sendPayment()` (guarded by a `typeof` check), so refunds were silently never sent while still reporting success. Now uses the real `processPayout()` (validates address + transfers, returns `{success, txHash}`) and refunds the **actually received** amount (`received_amount`, falling back to expected).
- [x] **1.6 S — `credits` → BIGINT; idempotent 010; atomic migration runner.** ✅ Migration 018 widens `users.credits` + `credit_transactions.amount/balance_after` to BIGINT. Migration 010 rewritten with conname-guarded `DO` blocks (re-runnable, no startup wedge). Runner now applies each migration **and** records the ledger row in one transaction (no applied-but-unrecorded state). Verified end-to-end: all 18 migrations applied cleanly to a throwaway Postgres DB, re-run skipped via the ledger, `credits` confirmed `bigint`, all CHECKs present.
## Phase 2 — Identity, abuse & concurrency (🟠)
- [x] **2.1 L — Unify identity resolution on stable `user_id`.** ✅ `getOrCreateUser` now resolves through SessionManager's stable `anon_token`→`id` identity first (re-selecting a fresh row by stable id), falling back to the legacy `socket_id` lookup only when no session exists — eliminating the duplicate "orphan" user row that caused credit/payout desync. `gameModeManager.sessionManager` wired in SocketHandlers. `anon_token` is already `UNIQUE` (migration 003), so the stable-identity constraint exists. New `identityResolution.test.js` proves no duplicate row is created when a session exists. _Deferred: re-keying the in-memory `activeGames` map by `user_id` (invasive; already mitigated in 0.5 by stamping `dbUserId` on the game) — tracked as a follow-up._
- [x] **2.2 M — Reconnect-proof rate limiting.** ✅ New `rateLimitContext` helper resolves a STABLE id (`anon_token`→`id`, falling back to socket id) + the real client IP. All socket-event limiters (game:start, address:set, chat:message) now pass both — previously they passed `socket.id` and **no IP at all**, so reconnecting reset every limit and the "IP-limited" prefixes never actually applied. `chat:` added to IP-limited prefixes; `connection:new` uses the real IP. Express `trust proxy` + XFF honored only when `TRUST_PROXY=true` (documented in `.env.example`). Contradictory default limit values aligned. New `rateLimit.test.js` (reconnect can't bypass, IP backstop, XFF gating).
- [x] **2.3 M — Input validation + leak fixes.** ✅ `register_client` validates `clientId` (type/length/charset) before it enters `clientSocketMap` — closes the map-poisoning vector. `mempoolNotified` is now a Map with real TTL eviction in PaymentHandlers (the dead duplicate Set + its random-10% eviction removed from SocketHandlers). `sessions` cache evicted on disconnect (`removeSocket`, called after suspend reads it). `sessionManager.dispose()` / `suspendedGameManager.cleanup()` / `paymentHandlers.dispose()` wired into `shutdown()`. `checkGamesTimeout` snapshots before iterating + awaits sequentially (was mutate-during-`forEach` + fire-and-forget). New `inputValidation.test.js`.
## Phase 3 — Game integrity (🟠)
- [x] **3.1 S — Anti-instant-death grace only (NO fairness floor).** ✅ Random block timing is the game's core mechanic and is deliberately preserved — early entry is a knowingly-risky bet, not a bug. The only change is a tiny configurable grace (`GAME_START_GRACE_MS`, default 2000ms, 0 to disable) so a block landing the instant a game starts can't kill the player before the dungeon renders / before their first move is possible. _(Rejected the originally-planned uniform time budget — it would gut the random-timing mechanic.)_
- [x] **3.2 S — Reachability guarantee.** ✅ `DungeonGenerator.generate` now BFS-checks entrance→exit reachability and regenerates if unreachable (paid-but-unwinnable guard). Retries are **deterministic** (per-attempt ROT seed = `seedInt+attempt`, seeded rng stream advances), so verify-regeneration replays the same attempts and reproduces the exact dungeon — provably-fair determinism preserved. New `isReachable` + tests (25 seeds all reachable; walled-off target rejected).
- [x] **3.3 S — Multi-step monster collision fix.** ✅ Collision is now checked after **each** monster sub-step, not just after the loop. With `movesPerPlayerMove > 1` (casino's 1.5× speed) the monster could previously step onto the player's tile and off again between checks, phasing through and missing the catch (player-favorable, eroded the house edge). New `monsterCollision.test.js`.
## Phase 4 — De-spaghetti / structural cleanup (🟡)
- [x] **4.1 L — Break up `index.js` (substantially).** ✅ Extracted the verify HTML → `views/verifyPage.js` (now HTML-escaped), all 15 admin endpoints → `routes/admin.js`, and the 3 smirk endpoints → `routes/auth.js`, each as DI factories with smoke tests. **index.js: 2029 → 882 lines (-57%).** _Remaining user/public routes left inline — they're interleaved with bootstrap and have no route tests, so further slicing is diminishing-return/higher-risk; the structure + pattern are established for later._
- [~] **4.2 M — Kill duplicates (partial).** ✅ Deleted the dead `handlePlayerMove` handler (a footgun that double-stepped the monster + bypassed escape/collision detection) and its orphaned `playerMoveTimestamps` map/cleanup — the live path is `MovementManager`. _Remaining (lower-value/higher-effort): merge `rpcService`+`rpccalls`, unify `payout_address` column widths, address the `QueryValidator` false-positive — deferred._
- [⏸] **4.3 M — Config snapshots — DEFERRED (risk > value).** The `process.env` config write-back is ugly but **load-bearing**: it's how DB-persisted hot-reload config reaches the env-based readers — `difficultyConfig` selects the difficulty preset from `process.env.GAME_MODE`, and `debugManager`'s simulated-blocks **safety gate** reads it. A proper `ConfigService` means rewiring those sensitive readers; with no config-reload tests and zero correctness/safety upside, the risk isn't justified right now. Documented rather than rewritten.
- [~] **4.4 M — Frontend de-spaghetti (safe wins done).** ✅ Shared `escapeHtml` already added in 0.6. Removed the duplicate `_gameMode` declaration (the later `null` silently shadowed the earlier `'free'`). Removed the dead `SocketHandlers._appendMessage` calls in `smirkAuth.js` (the method never existed; always fell through to the working `.text()` append). Gated `broadcastManager`'s high-volume per-event logs (user-count, chat-message-with-PII, game-update, game-start) behind debug mode. Upgraded the same-origin vendored jQuery from 3.4.1 to distro-sourced 3.7.1 with a recorded package version and SHA-256. _Deferred: the nested-`setTimeout` init-ordering rewrite and a complete nonce-based CSP migration (both require broader browser regression coverage)._
- [x] **4.5 S — Delete stale `test/` scratch files.** ✅ Removed 14 non-jest leftovers (5 `.js` scratch scripts + 9 `.html` debug pages) — confirmed they contained no jest constructs and were referenced nowhere. `test/` is now exclusively the 21 real jest suites.
## Phase 5 — Test & ops overhaul (🟠 — the launch gate)
- [ ] **5.1 L — Real money-path tests** against Postgres (no stubbed `withTransaction`): under/overpay, double-confirm, batch dispatch, credit race, retry idempotency; property-test `money/atomic.js`.
- [ ] **5.2 S — Run real `wownero-wallet-rpc` integration test** (open in TODO since day one).
- [ ] **5.3 S — `environmentValidator` hard-stop** (`process.exit(1)`); add admin-key/mainnet/simulated-block checks (`src/index.js:33`).
- [ ] **5.4 S — Reconcile docs** (README vs `.env.example` house odds); document real provably-fair.
- [ ] **5.5 M — Durability:** DB/Redis-backed idempotency + rate limiter.
- [ ] **5.6 S — TLS** via reverse proxy.
---
## Sequencing
Build `money/atomic.js` (1.1) and per-game RNG (0.2) **early** — Phases 1/3/4 depend on them.
```
Phase 0 (days) → freeze features, ship → Phase 1 (+5.1/5.2 in parallel)
→ Phase 2 → Phase 3 → Phase 4 (safe now) → finish Phase 5 → LAUNCH GATE
```
## Launch gate (real funds)
- [ ] Under/overpayment correct + reconciled (0.1)
- [ ] Provably-fair reproduces dungeon, or claim removed (0.2)
- [ ] No double-payout under batch+retry, test-proven (0.3, 1.3, 1.4, 5.1)
- [ ] All money math BigInt, property-tested (1.1)
- [ ] One identity, impersonation closed (0.4, 2.1)
- [ ] XSS closed + CSP (0.6)
- [ ] Real wallet-rpc integration test executed (5.2)
- [ ] Validator hard-stops on misconfig (5.3)
- [ ] TLS in front (5.6)
_Estimate: ~3–4 weeks to launch gate; Phase 0 in days._
+107 -81
View File
@@ -1,39 +1,55 @@
# Render kit: modes, packs & FX
# Render kit: modes, packs and 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.
The client render kit (`html/js/render/`, namespace `RK`) draws the same `Scene` in several
techniques and art styles, so the tavern and the dungeon are interchangeable environments and new
art can be shipped as a product without touching gameplay code.
## 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.
- **Render modes are techniques** (how a scene is drawn): `Tiled`, `ASCII`, `Iso`, `3D`
(`renderModes.js`). Each declares a `projection` of `topdown`, `iso`, or `3d`.
- **Packs are the sets and styles within a projection**, chosen from the pack picker and registered
in `packRegistry.js` / `assetPacks.js`.
- A premium mode is usable once the account has any unlocked pack for that mode's projection
(`RK.canUseMode`).
- The scene model is kind-based (`floor`, `wall`, `table`, `torch`, `monster`, and so on), so one
pack renders both the tavern (`sceneFromTavern`) and the dungeon (`sceneFromGameState`). That is
what makes packs interchangeable across town and dungeon.
Entitlement: packs are gated by the operator-owned cosmetic catalog (`cosmetic_catalog` table,
`src/multiplayer/entitlements.js`) — free / grant / lifetime-spend / subscription-tier. See
`MONETIZATION.md`.
Registered packs (`assetPacks.js`):
For browser QA, `?unlock=1` sets a sticky local bypass for render-mode and pack display gates;
`?unlock=0` clears it. This affects only that browser's `localStorage`, grants no server
entitlement, and is not an operator access-control mechanism.
| Pack id | Projection | Label |
|---------|-----------|-------|
| `original` | topdown | Original Tiles |
| `roguelike-interior` | topdown | Roguelike Interior |
| `roguelike-dungeon` | topdown | Roguelike Dungeon |
| `iso-dungeon` | iso | Isometric Dungeon |
| `iso-medieval` | iso | Medieval Town |
| `kenney-3d-characters` | 3d | Animated 3D |
The active pack is stored per projection (`rk_pack_<projection>` in `localStorage`, an in-memory map
under Node). A stored pack that is unregistered or locked is ignored in favour of the first unlocked
pack for that projection.
Entitlement is per pack, not per purchase: each pack maps to a row in the operator-owned
`cosmetic_catalog` table and resolves through `src/multiplayer/entitlements.js` as free, grant-only,
lifetime-credits threshold, or subscription tier. `entitlements.js` also carries a built-in default
catalog used when the table is unavailable. See `MONETIZATION.md`.
For browser QA, `?unlock=1` sets a sticky local bypass for render-mode and pack display gates and
`?unlock=0` clears it. It affects only that browser's `localStorage`, grants no server entitlement,
and is not an operator access-control mechanism.
## Gameplay focus contract
Single-player keyboard movement is deliberately accepted only while `#game-display` owns browser
focus, so typing in chat or another form cannot move the player. Choosing Tiled, ASCII, Iso, 3D, a
pack, or a camera control immediately returns focus to that gameplay area without scrolling.
Single-player keyboard movement is accepted only while `#game-display` owns browser focus, so typing
in chat or another form cannot move the player. Choosing Tiled, ASCII, Iso, 3D, a pack, or a camera
control immediately returns focus to the gameplay area without scrolling (`RK.SPGame.focusGameplay`).
## Local 3D runtime
Three.js `0.160.0` is an exact production dependency. The server advertises the local version in
`/runtime-config.js` and exposes only the browser build and required addons beneath:
`/runtime-config.js` and publishes only the browser build and the two required addons:
```text
/vendor/three/0.160.0/three.module.min.js
@@ -41,85 +57,95 @@ Three.js `0.160.0` is an exact production dependency. The server advertises the
/vendor/three/0.160.0/addons/utils/SkeletonUtils.js
```
The modules are imported lazily when 3D is selected. Local delivery is always enabled in the
application runtime and takes precedence over external sources. `RENDERER_CDN_ENABLED=true` makes
the jsDelivr renderer source path eligible only in a runtime where local delivery is disabled. All
checked-in environment profiles keep external renderer execution off, including production, so
Three.js remains same-origin under CSP.
The modules are imported lazily the first time 3D is selected (`RK.ensureThree`), so ASCII and Tiled
users never download them. Local delivery is always enabled in the application runtime and takes
precedence over external sources. `RENDERER_CDN_ENABLED=true` makes the jsDelivr renderer source
path eligible only in a runtime where local delivery is disabled, and widens the CSP to trust
third-party script. All checked-in environment profiles keep it off, so Three.js stays same-origin.
Three.js library delivery is separate from generated model delivery. Runtime GLBs under
`html/assets/generated/3d/` are gitignored and must be provisioned independently; the renderer
keeps a low-poly fallback when a model cannot load and removes it once the animated GLB is attached.
Library delivery is separate from model delivery. Runtime GLBs under `html/assets/generated/3d/` are
gitignored and must be provisioned independently; the renderer keeps a low-poly placeholder while a
model cannot load and removes it once the animated GLB is attached.
## 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:
Pure-canvas animated FX, CSP-safe and with 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
- `RK.fx.fire` and `RK.fx.flame`: dancing two-layer flame plus warm glow, for torches and hearths.
- `RK.fx.hazard`: pulsing overlay for hazard tiles (`lava`, `poison`, `spikes`), taking a
footprint-clip callback so it fits square cells and iso diamonds alike.
- `RK.fx.flicker` / `pulse` / `glow` — the primitives.
- `RK.fx.flicker`, `RK.fx.pulse`, `RK.fx.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.
A tile carries `fx: 'fire'` or `hazard: '<kind>'` in the scene legend (`sceneModel.js`). The
renderer draws only the tile's base in the static pass and lets `RK.fx` paint the animated layer in
the live loop. The tavern places wall braziers and a hearth. Dungeon hazards are keyed off the map
characters `L`, `P`, and `^` in `dungeonTileKind`, so a generator that emits them renders hazards
with no renderer change.
## Lighting model (iso & 3D)
Torches are wall-mounted: the generator places them only on wall cells adjacent to floor, so
`DUNGEON_LEGEND.torch` uses `over: 'wall'` and the fire branch draws the wall base plus the
procedural flame.
Both premium projections light the dungeon rather than just tinting it, and both derive their
lighting from the same `scene.lightGrid` the 2D modes use — so switching projection changes the
technique, never what you are allowed to see.
## Lighting model (iso and 3D)
Both premium projections light the dungeon rather than tinting it, and both derive lighting from the
same `scene.lightGrid` the 2D modes use, so switching projection changes the technique and never
what a player is allowed to see.
**Iso** composites in one pass per frame:
- tiles are **shaded opaque** (`_shadedImage`, quantized to 6 cached levels) instead of alpha-faded.
Alpha-fading made remembered walls translucent, so the dungeon read as a haze of overlapping
ghosts; multiplying a sprite's own pixels toward a cold near-black keeps distant stone *solid and
unlit*, which is what memory should look like;
- **light pools** (`_lightPool`) are additive, squashed to the ground plane, and emitted by torches
(flickering), the player's lantern, and objective beacons on the stairs/treasure;
- **crevice AO** (`_wallAO`) darkens floor cells by how many walls they touch;
- **embers** rise off each flame, seeded per cell so they don't reshuffle every frame;
- a **cold blue wash** (`_memoryWash`) separates remembered rooms from torchlit ones by colour
temperature. The wash colour must stay *darker* than the tiles it covers — a mid-tone blue lifts
- tiles are shaded opaque (`_shadedImage`, quantized to six cached levels) rather than alpha-faded.
Multiplying a sprite's own pixels toward a cold near-black keeps distant stone solid and unlit;
alpha-fading would make remembered walls translucent and let the tiles behind them show through.
- light pools (`_lightPool`) are additive, squashed to the ground plane, and emitted by torches
(flickering), the player's lantern, and objective beacons on the stairs and treasure.
- crevice AO (`_wallAO`) darkens floor cells by how many walls they touch.
- embers rise off each flame, seeded per cell so they do not reshuffle every frame.
- a cold blue wash (`_memoryWash`) separates remembered rooms from torchlit ones by colour
temperature. The wash colour must stay darker than the tiles it covers: a mid-tone blue lifts
memory brighter than the lit centre and inverts the depth hierarchy.
**3D** (`threeRenderer.js`) draws the level as **three InstancedMeshes** (walls / ground / props),
so a 70×35 dungeon costs three draw calls and a fog-of-war update is a few thousand matrix writes
rather than ~2500 mesh allocations. Wall and floor materials are generated on a canvas at startup
(coursed stone, irregular flagstones, both doubling as bump maps) — no new downloads, nothing for
the CSP to block. On top: a low warm key light with a player-tracking shadow frustum, a pool of six
point lights reassigned each frame to the nearest braziers, a hand-held lantern, drifting dust motes,
and a **wall cutaway** that squashes the walls between the isometric camera and the player.
**3D** (`threeRenderer.js`) draws the level as three InstancedMeshes (walls, ground, props), so a
70x35 dungeon costs three draw calls and a fog-of-war update is a few thousand matrix writes instead
of thousands of mesh allocations. Wall and floor materials are generated on a canvas at startup
(coursed stone, irregular flagstones, both doubling as bump maps), so there is nothing extra to
download and nothing for the CSP to block. On top of that: a low warm key light with a
player-tracking shadow frustum, a pool of six point lights reassigned each frame to the nearest
braziers, a hand-held lantern, drifting dust motes, and a wall cutaway that squashes the walls
between the isometric camera and the player.
Two things that are easy to get wrong here:
Two constraints that are easy to get wrong:
- **Fog is measured from the CAMERA**, which sits ~17 world units back along the isometric offset —
- Fog is measured from the camera, which sits about 17 world units back along the isometric offset,
not from the player. A fog band tuned as if it started at the player puts the player a quarter of
the way into fog and crushes the scene to near-black.
- **Do not apply `lightGrid` to albedo at full strength in 3D.** The point lights already provide
falloff; multiplying by the grid as well double-darkens every surface. 3D compresses it
(`0.38 + 0.62·lb`) and lets the lamps shape the scene.
- `lightGrid` must not be applied to albedo at full strength in 3D. The point lights already provide
falloff, so multiplying by the grid as well double-darkens every surface. 3D compresses it to
`0.38 + 0.62 * lb` and lets the lamps shape the scene.
Features (`entrance` / `exit` / `treasure` / items) are **set dressing, not avatars** in both
projections: stairs with a beacon shaft, a gold chest, a spinning pickup. Before that distinction
existed, every one of them was drawn as a humanoid, so the entrance looked like a second player
standing at the map origin.
Features (`entrance`, `exit`, `treasure`, items) are set dressing, not avatars, in both projections:
stairs with a beacon shaft, a gold chest, a spinning pickup. Rendering them through the entity path
would draw each one as a humanoid, which makes the entrance look like a second player standing at
the map origin.
## Other pieces
## Module reference
| 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. |
| `threeRenderer.js` | Same-origin Three.js scene. Instanced level geometry, procedural stone/flagstone materials, torch light pool + shadows, dedicated feature/monster meshes, dust motes, wall cutaway, fog-aware entity retention, animated GLB cloning, camera follow, and complete renderer/resource cleanup. |
| `chainAmbience.js` | `RK.mountChainAmbience(host)` — drifting top-block-hash fragments, a block-height readout, and a ripple when a block lands. Consumes the public `blockheight` broadcast; decoration only, and it pauses/prunes itself while the tab is hidden. |
| `spGameRenderer.js` | Single-player render bridge, player-centred camera, local 3D lazy-load, and explicit gameplay-focus restoration. |
| `sceneModel.js` | Renderer-agnostic `Scene` plus the tavern and game-state adapters, the dungeon legend, and tile-character mapping. Handles both array grids and the client's sparse `{y:{x:v}}` maps. |
| `renderModes.js` | Mode registry, entitlement checks, renderer factory, and lazy Three.js loading. |
| `tileRenderer.js` | Top-down atlas renderer. `over` compositing (object tiles drawn on a base tile), torch-lit vignette, ember and flicker loop, animated fire and hazard emitters. |
| `asciiRenderer.js` | Glyph renderer for the free ASCII mode. |
| `isoRenderer.js` | Canvas isometric projection. Orientation-aware walls (`_S` for x-runs, `_W` for y-runs, corner tile), floor variety, contact shadows, vignette. Kenney iso dungeon set (`html/assets/kenney/iso-dungeon/`). |
| `threeRenderer.js` | Same-origin Three.js scene. Instanced level geometry, procedural stone and flagstone materials, torch light pool and shadows, dedicated feature and monster meshes, dust motes, wall cutaway, fog-aware entity retention, animated GLB cloning, camera follow, and full renderer and resource cleanup. |
| `packRegistry.js` | Multi-pack-per-projection registry: active-pack selection persisted per projection, entitlement-gated, graceful fallback. Node-testable. |
| `assetPacks.js` | Pack definitions and asset payloads; registers the shipped packs. |
| `zoomControl.js` | `RK.attachZoom(host)` and `RK.attachCamera(host)`: wheel and pinch zoom (0.4 to 4.0), double-click reset, pixelated scaling. |
| `fxLayer.js` | Shared canvas FX primitives, fire, and hazard overlays. |
| `catSprites.js` | Animated tavern cat (Pet Cats Pack idle strips, 10 frames of 50x50). |
| `chainAmbience.js` | `RK.mountChainAmbience(host)`: drifting top-block-hash fragments, a block-height readout, and a ripple when a block lands. Consumes the public `blockheight` broadcast, is decoration only, and pauses and prunes itself while the tab is hidden. |
| `spGameRenderer.js` | Single-player render bridge (`RK.SPGame`): player-centred camera, local 3D lazy-load, gameplay-focus restoration, and fallback to the legacy render engine when the kit is unavailable. |
Tests: `test/renderPackResolver.test.js`, `test/packRegistry_ultra.test.js`,
`test/rendererCdnPolicy.test.js`, and `test/pvpUiPolish.test.js`.
Tests: `test/renderScene.test.js`, `test/renderPackResolver.test.js`, `test/packRegistry_ultra.test.js`,
`test/rendererCdnPolicy.test.js`, `test/pvpUiPolish.test.js`.
+166 -68
View File
@@ -1,97 +1,195 @@
# Rulesets & PvP (P4)
# Rulesets
Gameplay is a **data object**. Instead of hardcoding "first to the exit wins," the match engine reads a
`Ruleset`, so new modes (PvP last-alive, score attack, co-op) are data — not new subsystems.
Gameplay is a data object. The match engine reads a `Ruleset` instead of hardcoding "first to the
exit wins," so additional modes (PvP last-alive, score attack, co-op escape) are configuration rather
than new subsystems.
## The Ruleset
## The Ruleset object
`src/game/rulesets/Ruleset.js` — `defineRuleset(spec)` normalizes a partial spec to a frozen, clamped
object:
`src/game/rulesets/Ruleset.js` exposes `defineRuleset(spec)`, which normalizes a partial spec into a
complete, frozen, range-clamped ruleset. An empty spec yields the classic single-dungeon escape.
```
id, label, mode
world { difficultyPreset, maps }
entities { monster, monsterCount, pvpCombat }
players { min, max }
winCondition { type } first-to-exit | last-alive | high-score | all-escape
timing { tickMs, minDurationMs, hardCeilingMs, blockDeadline }
economy { model, houseFeePercent, payoutMultipliers }
```
| Field | Meaning | Default | Clamp |
| --- | --- | --- | --- |
| `id`, `label` | Identity and display name | `custom` / `Custom` | |
| `mode` | Coarse family: `solo`, `race`, `pvp`, `coop` | `race` | |
| `world.difficultyPreset` | Key passed to `getDifficultyConfig` | `normal` | |
| `world.maps` | Reserved for multi-level matches; the match engine generates one map | `1` | 1..10 |
| `entities.monster` | Spawn the shared monster | `true` | |
| `entities.monsterCount` | Reserved count field | `1` (`0` when `monster` is false) | 0..8 |
| `entities.pvpCombat` | Stepping onto a living rival strikes them down | `false` | |
| `players.min` / `players.max` | Entrant bounds; `min` is lowered to `max` if it exceeds it | `1` / `4` | 1..32 |
| `winCondition.type` | `first-to-exit`, `last-alive`, `high-score`, `all-escape` | `first-to-exit` | |
| `timing.tickMs` | Engine tick interval | `250` | 50..5000 |
| `timing.minDurationMs` | Active-play floor before a block deadline may fire | `20000` | 0..600000 |
| `timing.hardCeilingMs` | Absolute match length ceiling | `240000` | 1000..3600000 |
| `timing.blockDeadline` | End on the first advancing canonical header | `true` | |
| `economy.model` | `free`, `credits_prestige`, `crypto_race` | `free` | |
| `economy.houseFeePercent` | House rake for pot modes | `0` | |
| `economy.payoutMultipliers` | `escape`, `escapeWithTreasure` | `2`, `3` | |
| `metadata` | Free-form descriptor bag (`description` is surfaced to clients) | `{}` | |
`registry.js` ships the built-ins — `solo-classic` (descriptor for the live single-player mode),
`race`, `last-alive`, `score-attack`, `coop-escape` — plus `getRuleset(id)`, `listRulesets()`, and
`rulesetFromMatchOpts()` (maps the legacy MatchRoom option bag to a `race` ruleset, preserving old
behavior).
Unrecognized `winCondition.type` and `economy.model` values fall back to the defaults rather than
propagating, and every nested section is frozen.
## Built-in rulesets
`src/game/rulesets/registry.js` ships the catalog and the helpers `getRuleset(id, overrides)`,
`listRulesets()`, `listMatchRulesets()`, `resolveMatchRuleset(id)`, and `rulesetFromMatchOpts(opts)`.
| id | mode | players | win condition | notes |
| --- | --- | --- | --- | --- |
| `solo-classic` | `solo` | 1 | `first-to-exit` | Catalog descriptor for the single-player escape. Never selectable for a multiplayer queue. |
| `race` | `race` | 2..8 | `first-to-exit` | Classic escape race; `race` difficulty preset; `crypto_race` economy. |
| `last-alive` | `pvp` | 2..8 | `last-alive` | `pvpCombat` enabled, monster present, `normal` preset. |
| `score-attack` | `race` | 1..8 | `high-score` | Escaping does not end the match; score decides. |
| `coop-escape` | `coop` | 2..8 | `all-escape` | `pvpCombat` disabled; ends when every survivor escapes. |
`listMatchRulesets()` filters out `mode === 'solo'`, giving the server, scheduler, and clients one
stable multiplayer allowlist. `resolveMatchRuleset(id)` fails closed to `race` for unknown or
solo-only ids. `rulesetFromMatchOpts()` maps the legacy `MatchRoom` option bag
(`economy`, `difficultyPreset`, `maxPlayers`) onto a `race` ruleset; the legacy `variant` label does
not change the win condition.
## Win conditions as strategies
`src/game/rulesets/winConditions.js` — each condition is a small strategy the `MatchRoom` consults at
three decision points:
`src/game/rulesets/winConditions.js` implements each condition as a small strategy object that
`MatchRoom` consults at three decision points:
```
onExit(room, id) a player reached the exit — end the match? set a winner?
onDeath(room) a player died — is the match over?
onExit(room, id) a player reached the exit: end the match? set a winner?
onDeath(room) a player died: is the match over?
rank(room) final ordering (best-first) for placement; competitive modes also set winnerId
```
- **`FIRST_TO_EXIT`** is a *verbatim port* of the original race logic, so existing matches are
byte-identical.
- **`LAST_ALIVE`** (PvP): reaching the exit means you *survived* (safe, not an instant win); the match
ends when ≤1 contender remains; `rank` orders last-standing → escaped → died-later. Combined with
`entities.pvpCombat`, stepping onto a living rival **strikes them down** (attacker holds position).
Same-tick intents use a seed-derived priority over stable entrant slots, never packet arrival order;
an entrant eliminated earlier in that committed order cannot move or attack post-mortem.
- **`HIGH_SCORE`**: no instant win; rank by score at the end.
- **`ALL_ESCAPE`** (co-op): ends when everyone alive has escaped, records placements as progress
metadata, and deliberately leaves the individual `winnerId` null.
- **`FIRST_TO_EXIT`**: the first player out wins and the match ends immediately. Ranking orders
winner, then finished, alive, Manhattan distance to the exit, treasure, fewer moves, id.
- **`LAST_ALIVE`** (PvP): reaching the exit means you survived, not that you won; the match ends when
at most one contender remains in play and at least two started. Ranking orders last-standing,
escaped survivor, then later deaths above earlier ones (`deathOrder`), then treasure, distance, id.
With `entities.pvpCombat`, moving onto a living rival's tile strikes them down and the attacker
holds position.
- **`HIGH_SCORE`**: no instant win. Score rewards escaping, carrying treasure, move efficiency, and
proximity to the exit for players still inside when the match resolves.
- **`ALL_ESCAPE`** (co-op): ends when every player still alive has escaped (or all have died) and
reuses the race ordering for placements.
## Where it plugs in
`resolveWinCondition(type)` returns `FIRST_TO_EXIT` for any unknown type.
`MatchRoom` (`src/multiplayer/MatchRoom.js`) resolves a ruleset from `opts.ruleset` / `opts.rulesetId` /
legacy opts, then delegates: `_checkResolution` → `winCondition.onExit`, `_killPlayer` →
`winCondition.onDeath`, `finalize` → `winCondition.rank`. It honors `entities.monster` (optional monster)
and `entities.pvpCombat`, and takes timing from the ruleset. `MatchEngine` is a thin tick driver
(`room.resolveTick()` on an interval).
## Engine integration
**Scope note:** the *live single-player* engine (`src/game/game.js`) is intentionally **not** refactored to
consume rulesets (too risky to touch the deployed path) — `solo-classic` is a catalog descriptor only.
Multiplayer rulesets are selected by the server with `MATCH_RULESET_ID`; unknown or solo-only values fail
closed to `race`. The active ruleset and safe catalog metadata are advertised to clients, but clients cannot
select executable rules. Match mode remains gated by `MATCH_ENABLED` (inert by default).
`MatchRoom` (`src/multiplayer/MatchRoom.js`) resolves its ruleset with the precedence
`opts.ruleset` (an explicit spec), then `opts.rulesetId` plus optional `opts.rulesetOverrides`, then
`rulesetFromMatchOpts(opts)`. It then:
When `timing.blockDeadline` is true, `MatchScheduler` offers each advancing canonical header to
`MatchManager`. An active room expires only when that header is strictly later than its start
header and its active-play `minDurationMs` floor has elapsed. Duplicate polls and same-header
starts are idempotent; `blockDeadline: false` leaves the hard ceiling as the only clock-based end.
- spawns the shared monster only when `entities.monster` is set;
- takes `minDurationMs` and `hardCeilingMs` from `timing` (the manager and scheduler may override);
- clamps `maxPlayers` into 2..32 from `opts.maxPlayers` or `players.max`;
- delegates `_checkResolution` to `winCondition.onExit`, `_killPlayer` to `winCondition.onDeath`, and
`finalize` to `winCondition.rank`;
- treats co-op modes (`mode === 'coop'` or `all-escape`) as collective, recording placements as
progress metadata and leaving the individual `winnerId` null;
- exposes a client-safe summary (`id`, `label`, `mode`, `description`, `winCondition`, `pvpCombat`).
For paid multiplayer, the selected ruleset ID is part of the durable entrant freeze at canonical
header H. The seed uses only the exact configured future-header hash after that freeze and its
required confirmation wait; changing the active
ruleset cannot reinterpret a pending entrant set or trigger a server-random fallback.
Terminal players stay in `occupants` so spectators and final results can render them, but corpses and
escaped heroes are pass-through: co-op and score attack require several players to cross the single
exit tile on different ticks. Living, in-play players remain solid, and an occupant with no match
state stays solid rather than failing open into an overlap.
Same-tick intents resolve in a committed order derived from `sha256(seed:tick:entrantSlot)`, never
packet arrival order. An entrant eliminated earlier in that order cannot move or attack afterwards;
the attempt emits `move_failed` with reason `eliminated_before_action`.
`MatchEngine` (`src/multiplayer/MatchEngine.js`) is a thin, transport-agnostic tick driver that calls
`room.resolveTick()` on an interval and reports results to its owner.
The single-player engine (`src/game/game.js`) does not consume rulesets; `solo-classic` is a catalog
descriptor for parity and display only.
## Selection and safety
Multiplayer rulesets are trusted server configuration, selected with `MATCH_RULESET_ID` (default
`race`). Clients receive the active ruleset and the safe catalog metadata for display, but cannot
select or author executable rules. Match mode as a whole is gated by `MATCH_ENABLED`, which must be
the literal string `true` to be active.
Crypto payout admission requires single-winner semantics. `matchEconomyPolicy` accepts a ruleset only
when its mode is neither `solo` nor `coop`, its win condition is one of `first-to-exit`,
`last-alive`, or `high-score`, and `players.max` is at least 2; otherwise admission is denied with
`unsupported_crypto_ruleset`. `environmentValidator` applies the same check at boot and additionally
requires `MATCH_MAX_PLAYERS` to fall inside the ruleset's player contract. Match persistence and
transport require at least two entrants, so the effective minimum is raised to 2 even for rulesets
that are also valid as a one-player challenge.
`src/migrations/029_match_ruleset_id.sql` persists the selected ruleset on each match
(`matches.ruleset_id`, default `race`) with a partial index on finished matches, keeping newer
built-ins auditable without widening the money and economy keys.
## Timing and block deadlines
`matchScheduler` resolves timing with the precedence: explicit constructor argument, then
`MATCH_TICK_MS` / `MATCH_MIN_DURATION_MS` / `MATCH_HARD_CEILING_MS`, then the ruleset's `timing`,
then the built-in defaults.
When `timing.blockDeadline` is true, each advancing canonical header is offered to `MatchManager`.
An active room expires with reason `block_deadline` only when the header is strictly later than the
room's start header and its active-play `minDurationMs` floor has elapsed. Strict height comparison
protects rooms created by the same header, and duplicate polls are idempotent. With
`blockDeadline: false`, the hard-ceiling watchdog owned by `MatchManager` is the only clock-based end.
For paid multiplayer, the ruleset id is part of the durable entrant freeze at the freeze header:
`buildPaidEntrantFreeze` commits version, freeze height, target height, delay, economy, ruleset id,
and the canonical queue entry ids into a single hash. `deriveFutureBlockMatchSeed` then accepts only
the exact committed future header, after its required confirmation wait, and mixes the freeze
commitment into the seed. Changing the active ruleset cannot reinterpret a pending entrant set or
trigger a server-random fallback.
## Economy per mode
A ruleset's `economy` field (`model`, `houseFeePercent`, `payoutMultipliers`) maps onto the existing
`gameModeManager` fields (single-player: multiplier-on-entry-fee) and `matchPayoutService` (PvP:
winner-takes-pot-minus-house). See [MONETIZATION.md](MONETIZATION.md).
A ruleset's `economy` block (`model`, `houseFeePercent`, `payoutMultipliers`) maps onto the existing
payout paths: `gameModeManager` for single player (multiplier on entry fee) and `matchPayoutService`
for pot modes (winner takes the pot minus the house fee). See [MONETIZATION.md](MONETIZATION.md) and
[MATCH_MODE.md](MATCH_MODE.md).
## Pending: block-time-aware difficulty
## World presets and per-chain tuning
`difficultyConfig` presets (easy/normal/hard/casino) tune dungeon size, monster behavior, treasure, and a
target house-win-rate — but currently key off payment mode, **not** mean block time. Coupling difficulty
to `ChainProfile.meanBlockTimeMsFor(chain)` (so Grin ~1 min stays fair vs BTC ~10 min) is a pending
game-balance change, not just wiring.
`src/game/difficultyConfig.js` holds the world half of a ruleset. `getDifficultyPreset` selects a
preset from `DIFFICULTY_PRESET` or an explicit override; with neither set it returns `normal` for
free play (`GAME_MODE=FREE` and `PAYMENTS_ENABLED` not true) and `casino` otherwise. Presets tune
dungeon size and openness, monster start distance, speed, aggressiveness and vision, treasure
placement, and a target house win rate.
Block time enters through `NETWORK_TUNING`, which folds a per-chain level count onto the resolved
preset so a run's cumulative length scales with the chain's mean block time. Each level stays
base-sized with a fair monster; the house edge comes from racing the block across the whole descent
rather than from one oversized map or a faster-than-fair monster.
| Chain | Mean block time | Levels |
| --- | --- | --- |
| GRIN | ~1 min | 1 |
| XMR | ~2 min | 1 |
| LTC | ~2.5 min | 2 |
| WOW | ~5 min | 2 |
| BTC | ~10 min | 3 |
Precedence in `getDifficultyConfig` runs low to high: preset, per-chain tuning, environment variables
(`DUNGEON_LEVELS`, `DUNGEON_*`, `MONSTER_*`), then explicit overrides passed by the caller. Set
`NETWORK_TUNING_DISABLED=true` to skip the per-chain step. The level count drives multi-level solo
runs; `MatchRoom` consumes only the dungeon geometry from the resolved config and generates a single
shared map.
## Files
```
src/game/rulesets/Ruleset.js defineRuleset + WIN/ECONOMY enums
src/game/rulesets/winConditions.js FIRST_TO_EXIT (race) / LAST_ALIVE / HIGH_SCORE / ALL_ESCAPE
src/game/rulesets/registry.js built-ins + safe match resolver/catalog helpers
src/multiplayer/MatchRoom.js consumes the ruleset; PvP combat; delegates win/lose/rank
src/multiplayer/MatchEngine.js tick driver
src/network/matchScheduler.js advancing-header deadline dispatch
src/network/matchManager.js active-play floor + idempotent expiry
src/game/difficultyConfig.js world presets (the ruleset's `world` half)
src/game/rulesets/Ruleset.js defineRuleset + WIN / ECONOMY enums
src/game/rulesets/winConditions.js FIRST_TO_EXIT / LAST_ALIVE / HIGH_SCORE / ALL_ESCAPE
src/game/rulesets/registry.js built-ins + match resolver and catalog helpers
src/multiplayer/MatchRoom.js consumes the ruleset; PvP combat; delegates win/lose/rank
src/multiplayer/MatchEngine.js tick driver
src/network/matchScheduler.js advancing-header deadline dispatch
src/network/matchManager.js active-play floor + idempotent expiry
src/network/matchEconomyPolicy.js crypto admission contract (player bounds, win semantics)
src/network/matchFairness.js paid entrant freeze + future-block seed derivation
src/game/difficultyConfig.js world presets and per-chain level tuning
src/migrations/029_match_ruleset_id.sql persists the selected ruleset on each match
```
+210 -86
View File
@@ -1,59 +1,105 @@
# XMR stagenet financial canary
`src/scripts/stagenet-financial-canary.js` is the release-gate harness for Monerogue's two
direct-entry solo outcomes. Purchased-credit entry is outside the operated product profile. The
harness is intentionally narrow: one new anonymous user, one invoice, at most one funding
transfer, one bounded game, and one payout. It supports these scenarios:
`src/scripts/stagenet-financial-canary.js` is the release-gate harness for the two direct-entry
solo money outcomes. Purchased-credit entry is outside the operated stagenet profile and the
harness requires it to be disabled. The harness is deliberately narrow: one new anonymous user,
one invoice, at most one funding transfer, one bounded game, and one payout.
| `E2E_SCENARIO` | Entry | Required outcome | Exact payout |
|---|---|---|---|
| `direct-2x` | one `single_game` invoice | escape without treasure | invoice amount × 2 |
| `direct-3x` | one `single_game` invoice | collect treasure, then escape | invoice amount × 3 |
Stagenet XMR is valueless test currency. Never send mainnet XMR to a stagenet address. This harness refuses mainnet-facing application state and funding-wallet addresses, but that guard is not a substitute for checking the wallet and daemon commands yourself.
Stagenet XMR is valueless test currency. Never send mainnet XMR to a stagenet address. The harness
refuses mainnet-facing application state and mainnet funding-wallet addresses, but that guard is
not a substitute for checking the wallet and daemon commands yourself.
## Hard safety properties
## Safety envelope
The harness fails closed unless all of these are true:
The harness fails closed unless all of these hold.
- The application URL explicitly names `127.0.0.1`, `localhost`, or `::1` on dedicated port `3102`. It cannot target the public router, LAN hostname, or Nebula address.
- Readiness, public disclosures, REST mode data, and Socket.IO mode data all identify real XMR stagenet with payments and payout dispatch enabled.
- Direct solo is enabled with exact 2× escape and 3× treasure multipliers. Purchased-credit solo
and its payout path are disabled.
- The current `/api/disclosures` response requires the canonical five-field acknowledgement. The harness refetches it immediately before the value-bearing action and echoes exactly `policyVersion`, `ageEligible`, `termsRead`, `riskAccepted`, and `testnetUnderstood`.
- The selected PostgreSQL database name contains `canary`, `e2e`, and the selected scenario tag
(`direct` for 2× or `treasure` for 3×). Its connection must be localhost, the harness session is
read-only, current financial migrations must exist, and the database must contain only the
migration-seeded admin with no game or financial rows.
- Before any invoice, funding-wallet call, or transfer, a nonce challenge binds the application pool to the harness connection's exact PostgreSQL cluster identifier, database OID, and database name. A same-named database on another cluster does not pass.
- The funding wallet RPC is localhost-only and either digest-authenticated or enabled by a separate explicit unauthenticated-local-RPC acknowledgement.
- The funding wallet validates both its own address and the invoice as stagenet. `get_address_index` must prove the invoice does not belong to the funding wallet; this prevents a meaningless house-wallet self-payment.
- Live execution needs three exact confirmations plus an atomic-unit transfer ceiling. The transfer gate is marked used before the sole `transfer` RPC call, so an ambiguous transport failure is never retried.
- `E2E_TARGET` explicitly names `127.0.0.1`, `localhost`, or `::1` on dedicated port `3102`, with
no path, query, fragment, or embedded credentials. It cannot target a public hostname or a LAN
address.
- `/health/ready` reports a real (non-simulated) stagenet chain, database/chain/wallet checks up,
and both payment intake and payout dispatch enabled.
- `/api/game-modes`, `/api/stats`, and the Socket.IO `game_mode_info` payload all identify the
`such-monerogue-stagenet` operated profile, `sXMR`, direct entry and direct payouts enabled,
purchased-credit entry and credit payouts disabled, Smirk disabled, crypto match payouts
disabled, and match economies limited to `free` and `credits_prestige`. `PAID_CREDITS` must be
absent from the public mode descriptors entirely.
- Direct payout multipliers are exactly 2 for escape and 3 for escape with treasure, in both the
REST and Socket.IO views.
- `/api/disclosures` is `no-store`, requires paid-action acknowledgement, carries the operated
product's `2×/3×` scope notice and `NO REAL VALUE` warning, and exposes `/terms`, `/privacy`,
and `/responsible-play`. The harness echoes exactly `policyVersion`, `ageEligible`, `termsRead`,
`riskAccepted`, and `testnetUnderstood`, and refetches the disclosure immediately before the
value-bearing action so a policy change aborts the run before any invoice exists.
- The selected PostgreSQL database passes the naming rules below, connects over loopback, is
opened with `default_transaction_read_only = on`, carries the required financial migrations, and
contains only the migration-seeded admin with no game or financial rows.
- Before any invoice, funding-wallet call, or transfer, a nonce challenge binds the application's
own pool to the harness connection's exact PostgreSQL cluster identifier, database OID, and
database name. A same-named database on another cluster does not pass.
- The funding wallet RPC origin is loopback-only and is either digest-authenticated or explicitly
enabled with `E2E_ALLOW_UNAUTH_FUNDING_RPC=I_ACCEPT_LOCAL_UNAUTH_RPC`.
- The funding wallet validates both its own primary address and the invoice address as stagenet.
`get_address_index` must fail on the invoice address, proving the invoice does not belong to the
funding wallet and the run is not a house-wallet self-payment.
- Live execution requires three exact confirmation strings (`E2E_CONFIRM`,
`E2E_SCENARIO_CONFIRM`, `E2E_CANARY_PROFILE`), an explicit `E2E_MAX_TRANSFER_ATOMIC` ceiling, and
the database nonce file. The one-shot transfer gate is marked used before the sole `transfer` RPC
call, so an ambiguous transport failure is never retried.
- The bot is bounded to one dungeon depth, 3,000 moves, and eight minutes. The 2× scenario treats
treasure as blocked. The 3× scenario treats the exit as blocked until treasure is collected.
- Wallet addresses, session tokens, proof seeds, transaction hashes, and credentials are never printed. Failure messages redact those shapes.
the treasure tile as blocked; the 3× scenario treats the exit tile as blocked until treasure is
collected. Both scenarios treat the monster tile as blocked.
- Server-side fog of war is asserted, not bypassed: a start state that already names the exit or
the treasure fails the run. The bot explores frontiers until an objective is revealed.
- Wallet addresses, session tokens, proof seeds, transaction hashes, and credentials are never
printed. Failure messages redact those shapes.
The harness never creates, restores, exports, migrates, or backs up a wallet. It never deploys code, changes router state, or edits fleet configuration.
The harness never creates, restores, exports, migrates, or backs up a wallet, and performs no
deployment or infrastructure change. Its only write actions are the Socket.IO gameplay events, the
single funding transfer, and whatever the application itself persists in response.
## Required topology
Use two different stagenet wallets:
Use two different stagenet wallets.
1. The canary application owns the normal house wallet used to create invoice subaddresses and dispatch payouts.
1. The canary application owns the normal house wallet that creates invoice subaddresses and
dispatches payouts.
2. A separate, low-balance funding wallet sends the direct-entry payment and receives the payout.
Its primary address (account 0, index 0) is registered as the canary identity's payout address.
Expose only the separate funding wallet RPC to the harness, on loopback. Use a dedicated port such
as `38085`; port `38083` belongs to the promoted house wallet and must not be reused by the funding
wallet. Do not give the harness the house-wallet RPC URL or credentials. Keep the funding balance
no larger than needed for the selected invoice plus fees.
Expose only the funding wallet RPC to the harness, on loopback. Use a dedicated port such as
`38085`. In `src/.env.stagenet.example`, port `38083` belongs to the promoted house wallet and must
not be reused: pointing the harness at it would hand a test script the RPC that holds real balances
and dispatches payouts. Do not give the harness the house-wallet RPC URL or credentials. Keep the
funding balance no larger than the invoice plus fees.
Before live use, independently verify that both wallet files are recoverable according to the operator's wallet-backup procedure. Do not put seeds, private keys, wallet passwords, transaction keys, or mnemonic output in this runbook, shell history, screenshots, CI variables, or canary logs. This harness deliberately does not inspect or print seed material.
Before live use, independently verify that both wallet files are recoverable under the operator's
wallet-backup procedure. Do not put seeds, private keys, wallet passwords, transaction keys, or
mnemonic output in this runbook, shell history, screenshots, CI variables, or canary logs. The
harness deliberately does not inspect or print seed material.
## Build the disposable canary instance
Run the exact release candidate intended for production, but bind it only to localhost port `3102`. Use a dedicated PostgreSQL database for each scenario; do not run both scenarios in the same database and never reuse a database after any live attempt.
Run the exact release candidate intended for production, bound only to localhost port `3102`. Use
a dedicated PostgreSQL database per scenario. Do not run both scenarios against one database, and
never reuse a database after any live attempt.
Recommended game-profile overrides for the one-shot financial check are:
The instance must use the intended production money configuration, which
`src/.env.stagenet.example` already encodes:
- `CRYPTO_TYPE=XMR`, `MONERO_NETWORK=stagenet`, a real stagenet daemon and house wallet RPC;
- `OPERATED_PRODUCT_PROFILE=such-monerogue-stagenet`;
- `PAYMENTS_ENABLED=true`, `DIRECT_PAYMENT_ENABLED=true`, `DIRECT_PAYOUTS_ENABLED=true`,
`PAYOUTS_ENABLED=true`, `ALLOW_MAINNET_PAYOUTS=false`;
- `CREDITS_ENABLED=false`, `CREDITS_PAYOUTS_ENABLED=false`;
- `DIRECT_PAYOUT_ESCAPE=2.0`, `DIRECT_PAYOUT_TREASURE=3.0`;
- `PAID_ACKNOWLEDGEMENT_REQUIRED=true` with current operator and legal disclosure metadata.
Recommended game-profile overrides for the one-shot financial check:
```dotenv
DIFFICULTY_PRESET=easy
@@ -61,26 +107,39 @@ MONSTER_SPEED=0
DUNGEON_LEVELS=1
```
`MONSTER_SPEED=0` leaves the verifiable monster placement in the committed dungeon but prevents movement during the canary. The harness verifies the one-level state; the exact `E2E_CANARY_PROFILE` confirmation below is the operator's assertion that the easy/static-monster configuration is the one actually running.
`MONSTER_SPEED=0` leaves the monster placed in the committed dungeon, so its seeded position stays
verifiable, but its per-turn move accumulator never reaches one step. The harness independently
asserts the one-level state; `E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL` is the operator's
assertion that this easy, static-monster configuration is the one actually running.
The canary instance must otherwise use the intended production money configuration, including:
### Scenario database names
- `CRYPTO_TYPE=XMR`, `MONERO_NETWORK=stagenet`, real stagenet daemon and house wallet RPC;
- direct paid mode and direct payouts enabled;
- purchased-credit mode and credit payouts disabled;
- exact direct 2× escape and 3× treasure multipliers;
- production paid-action acknowledgement enabled and current operator/legal disclosure metadata.
Both the harness and the application validate the database name, with slightly different rules,
and the name must satisfy both.
Use a fresh database name such as:
- The harness (`validateExpectedDatabaseName`) requires a simple `[a-z0-9_]` name containing
`canary`, `e2e`, and the scenario tag: `direct` for `direct-2x`, `treasure` for `direct-3x`.
- The application (`src/services/canaryDatabaseIdentity.js`) requires `canary`, `e2e`, and either
`direct` or `credits` as an underscore-delimited token.
Names that satisfy both:
```text
monerogue_canary_direct_e2e
monerogue_canary_treasure_e2e
monerogue_canary_direct_e2e # direct-2x
monerogue_canary_direct_treasure_e2e # direct-3x
```
First boot the release candidate against the new database with payout dispatch disabled solely to run startup migrations. Stop it after readiness. Then run database preflight. Finally boot the same release candidate and same database with the intended payout configuration on localhost port `3102`. Do not point a public router at this instance.
### Boot order
Enable the identity endpoint only on that final isolated canary boot:
1. Boot the release candidate against the new database with payout dispatch disabled, solely to
run startup migrations. Stop it once it reports ready.
2. Run the database preflight.
3. Boot the same release candidate against the same database with the intended payout
configuration on localhost port `3102`. Do not point a public router at this instance.
### Database identity endpoint
Enable the identity endpoint only on that final isolated boot:
```dotenv
NODE_ENV=production
@@ -90,15 +149,22 @@ CANARY_EXPECT_DATABASE=monerogue_canary_direct_e2e
CANARY_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce
```
Use the corresponding `treasure` database name for the 3× scenario. If none of the three
`CANARY_DATABASE_*` variables is set, the endpoint is not registered. A partial configuration,
non-XMR-stagenet process, non-production process, wrong port, or `DB_NAME` mismatch aborts startup.
The endpoint signs its database identity and a fresh caller challenge; it never returns the nonce.
It also rejects any TCP peer that is not loopback, without trusting proxy-forwarded client headers.
Use the corresponding `treasure` database name for the 3× scenario. If none of
`CANARY_DATABASE_HANDSHAKE`, `CANARY_EXPECT_DATABASE`, and `CANARY_DATABASE_NONCE_FILE` is set, the
endpoint is not registered. A partial configuration, a non-production process, a non-XMR-stagenet
process, a port other than `3102`, a non-canary database name, or a `DB_NAME` that differs from
`CANARY_EXPECT_DATABASE` aborts startup.
The endpoint serves `GET /api/canary/database-identity` as `no-store`. It requires a 256-bit
lowercase-hex `X-Canary-Database-Challenge` header, returns the cluster id, database OID, and
database name, and HMACs that tuple plus the challenge under the nonce. It never returns the nonce
itself. It rejects any TCP peer that is not loopback, judging by the socket's remote address rather
than proxy-forwarded client headers, so an accidentally routed canary discloses nothing.
## Protect local secret files
Credential files read by the harness must be regular files, not symlinks, and have no group/world permission bits (for example mode `0600` or `0400`). Prefer file-backed variables:
Credential files read by the harness must be regular files, not symlinks, and must have no
group or world permission bits (for example mode `0600` or `0400`). Prefer file-backed variables:
```text
E2E_FUNDING_RPC_USER_FILE=/run/credentials/canary/funding-rpc-user
@@ -107,16 +173,24 @@ E2E_DATABASE_URL_FILE=/run/credentials/canary/database-url
E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce
```
Create a new random 32-byte nonce for each scenario and expose the same protected file to the
application (`CANARY_DATABASE_NONCE_FILE`) and harness (`E2E_DATABASE_NONCE_FILE`). Do not put its
contents in an environment variable, command argument, log, or screenshot. Delete the nonce after
the isolated canary is stopped; it is not a wallet key or recovery artifact.
`E2E_FOO` and `E2E_FOO_FILE` are mutually exclusive for each secret.
Alternatively, `E2E_DATABASE_ENV_FILE` may point to a protected dotenv file containing `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD`. Set exactly one database source. `DB_HOST` and database URLs must explicitly use loopback.
Create a new random nonce for each scenario (`openssl rand -hex 32`) and expose the same protected
file to the application (`CANARY_DATABASE_NONCE_FILE`) and to the harness
(`E2E_DATABASE_NONCE_FILE`). The nonce file must be an absolute path holding exactly 64 lowercase
hex characters; it is opened with `O_NOFOLLOW`. Do not put its contents in an environment variable,
command argument, log, or screenshot. Delete the nonce after the isolated canary is stopped; it is
not a wallet key or recovery artifact.
Alternatively, `E2E_DATABASE_ENV_FILE` may point to a protected dotenv file containing `DB_HOST`,
`DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD`. Set exactly one database source. `DB_HOST` and
database URLs must explicitly name loopback, and the database they name must equal
`E2E_EXPECT_DATABASE`.
## Read-only preflights
The public preflight performs no Socket.IO session registration, invoice creation, database mutation, or wallet RPC call:
The public preflight performs no Socket.IO session registration, invoice creation, database
mutation, or wallet RPC call:
```bash
E2E_MODE=preflight \
@@ -124,7 +198,8 @@ E2E_TARGET=http://127.0.0.1:3102 \
npm --prefix src run canary:stagenet
```
The database preflight opens only a read-only PostgreSQL session. Run it while the freshly migrated database still contains no canary session:
The database preflight opens only a read-only PostgreSQL session and contacts nothing else. Run it
while the freshly migrated database still contains no canary session:
```bash
E2E_MODE=database-preflight \
@@ -134,12 +209,14 @@ E2E_DATABASE_URL_FILE=/run/credentials/canary/direct-database-url \
npm --prefix src run canary:stagenet
```
For the 3× scenario, use `E2E_SCENARIO=direct-3x`, a database name containing `treasure`, and its
separate protected connection file.
For the 3× scenario use `E2E_SCENARIO=direct-3x`, a database name containing `treasure`, and its
own protected connection file.
## Live direct 2× escape
Review the configured direct entry atomic amount before setting `E2E_MAX_TRANSFER_ATOMIC`. The ceiling must be at least the advertised invoice amount and should equal that amount when practical.
Review the configured direct entry price before setting `E2E_MAX_TRANSFER_ATOMIC`. The ceiling must
be at least the advertised invoice amount and should equal it when practical; with the template's
`DIRECT_GAME_PRICE=10000000000` (0.01 sXMR) the ceiling is `10000000000`.
```bash
E2E_MODE=live-stagenet \
@@ -159,19 +236,13 @@ E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL \
npm --prefix src run canary:stagenet
```
The harness binds a fresh fairness offer to the direct invoice before revealing its address, transfers the exact invoice amount, waits for receipt-backed confirmation and automatic game start, avoids treasure, escapes, and verifies an exact 2× payout.
## Live direct 3× treasure escape
Review the configured direct-entry atomic amount before setting the ceiling. As with the 2× run,
the ceiling must be at least the advertised invoice amount and should equal that amount when
practical.
```bash
E2E_MODE=live-stagenet \
E2E_SCENARIO=direct-3x \
E2E_TARGET=http://127.0.0.1:3102 \
E2E_EXPECT_DATABASE=monerogue_canary_treasure_e2e \
E2E_EXPECT_DATABASE=monerogue_canary_direct_treasure_e2e \
E2E_DATABASE_URL_FILE=/run/credentials/canary/treasure-database-url \
E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce \
E2E_FUNDING_RPC_URL=http://127.0.0.1:38085 \
@@ -185,35 +256,87 @@ E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL \
npm --prefix src run canary:stagenet
```
The harness binds a fresh fairness offer to the direct invoice before revealing its address,
transfers the exact invoice amount, waits for receipt-backed confirmation and automatic game
start, collects treasure before allowing the exit, and verifies an exact 3× payout based on the
invoice amount.
## Live run sequence
1. Register an anonymous Socket.IO client and capture its session token and `game_mode_info`.
2. Refetch `/api/disclosures` and abort if the policy version moved since preflight.
3. Save the funding wallet's primary address as the payout destination.
4. Request a fresh fairness offer, then create the `single_game` invoice bound to that offer and a
random 256-bit client seed. The invoice must echo proof version 2 with the same offer id,
commitment, and client seed, must not be a reused invoice, and must equal the advertised atomic
entry price.
5. Prove the invoice address is stagenet and is not owned by the funding wallet.
6. Broadcast the sole transfer of the exact invoice amount, after checking that unlocked balance
covers the amount plus `E2E_FEE_CUSHION_ATOMIC`.
7. Wait for `payment_confirmed` and the automatic `game_start`, and check the started dungeon is
bound to the invoice's fairness proof.
8. Run the bounded bot to the scenario outcome.
9. Verify the reveal: the server seed hashes to the pre-game commitment, the effective seed is
`HMAC-SHA256(serverSeed, clientSeed)`, and the outcome metadata matches.
10. Verify the committed payout amount and multiplier, poll the owned payout history until the
payout is `completed` with a transaction hash, and confirm the funding wallet observes the
exact incoming amount.
11. Run the database settlement assertions.
## Exact settlement assertions
After the funding wallet observes the incoming payout, the harness checks the read-only database connection. A passing run has exactly:
A passing run leaves the scenario database with exactly:
- two users: the migration-seeded admin and one canary identity;
- one confirmed native-Monero payment with `received_amount == expected_amount` and valid confirmation evidence;
- one unique confirmed `chain_output` receipt whose amount exactly equals the invoice;
- one won/escaped game with consumed entry evidence, committed 2×/3× immutable payout terms, the selected paid mode, matching fairness v2 identity, and the expected treasure flag;
- one completed payout with the exact amount, multiplier, reason, payout address, and transaction evidence;
- no refunds, late reviews, matches, match queues, race-entry ledgers, or race-entry lots.
- two users: the migration-seeded admin and one canary identity with a payout address and one
game played;
- one `confirmed` `native-monero` payment of `payment_type = single_game` with
`received_amount == expected_amount`, confirmation evidence, a valid transaction hash, and an
immutable invoice destination identity (`subaddress`, `provider_invoice_id`, `address_index`);
- product identity `single_game` granting zero credits, zero race entries, no packs, and no
premium level;
- fairness proof version 2 bound and consumed exactly once, matching the offer, commitment, and
client seed;
- one unique `confirmed` `chain_output` receipt whose amount exactly equals the invoice, whose
`evidence_id` is `tx_hash:output_id`, and whose address index matches the payment;
- one `won`/`escaped` game in the selected paid mode, with consumed entry evidence, a revealed
proof, the expected treasure flag, and immutable payout terms committing both the 2× escape and
3× treasure amounts;
- one `completed` payout with the exact amount, multiplier, reason
(`escape` or `escape_with_treasure`), payout address, and transaction evidence;
- exactly two credit-ledger rows, `direct_entry +1` and `game_entry -1`, both settling to a zero
balance with no linked payment id, leaving `credits = 0` and `total_credits_purchased = 1`;
- zero refunds, late reviews, entitlement grants, pack entitlements, matches, match entrants,
match events, match queue entries, race-entry transactions, and race-entry lots.
Each scenario additionally requires exactly the `direct_entry +1` and `game_entry -1`
credit-ledger rows, a zero balance, unified purchase progress of one, the game linked to the
fairness-bound payment, and no product-entitlement marker.
## Optional bounded tunables
Each is a positive integer with a hard safety bound; exceeding the bound fails the run.
| Variable | Default | Maximum |
|---|---|---|
| `E2E_PAYMENT_TIMEOUT_MS` | 1800000 | 2700000 |
| `E2E_PAYOUT_TIMEOUT_MS` | 900000 | 1800000 |
| `E2E_RPC_POLL_MS` | 2000 | 10000 |
| `E2E_BOT_MOVE_DELAY_MS` | 140 | 1000 |
| `E2E_BOT_MAX_MOVES` | 3000 | 3000 |
| `E2E_BOT_TIMEOUT_MS` | 480000 | 480000 |
`E2E_FEE_CUSHION_ATOMIC` defaults to `1000000000` and has no maximum beyond
`Number.MAX_SAFE_INTEGER`. `E2E_MAX_TRANSFER_ATOMIC` has no default and is mandatory for live runs.
## Failure handling
Before the line `one exact, non-retriable XMR stagenet transfer was broadcast`, a failure means no funding transfer was attempted. Correct the configuration, recreate a fresh scenario database if any app session was made, and repeat the preflights.
The harness prints `one exact, non-retriable XMR stagenet transfer was broadcast` immediately after
its sole `transfer` call.
At or after that line, never rerun the command and never reuse the database. A transport timeout can be ambiguous even when the wallet accepted the transfer. Preserve the database and both wallet files, stop the isolated canary, and reconcile the invoice receipt, payment, game, payout, and wallet histories manually. The harness intentionally performs no automatic retry, refund, database cleanup, wallet cleanup, or destructive recovery.
Before that line, a failure means no funding transfer was attempted. Correct the configuration,
recreate a fresh scenario database if any application session was made, and repeat the preflights.
Only after both scenarios pass on separate databases should the release be considered financially canary-tested. Passing does not authorize deployment; deployment and router/fleet changes remain separate reviewed operations.
At or after that line, never rerun the command and never reuse the database. A transport timeout
can be ambiguous even when the wallet accepted the transfer. Preserve the database and both wallet
files, stop the isolated canary, and reconcile the invoice receipt, payment, game, payout, and
wallet histories manually. The harness performs no automatic retry, refund, database cleanup,
wallet cleanup, or destructive recovery.
## Local verification (no network or wallet use)
A release is financially canary-tested only after both scenarios pass on separate databases.
Passing does not authorize deployment; deployment remains a separate reviewed operation.
## Offline verification
```bash
node --check src/scripts/stagenet-financial-canary.js
@@ -221,4 +344,5 @@ npm --prefix src test -- --runTestsByPath ../test/canaryDatabaseIdentity.test.js
npm --prefix src run canary:stagenet -- --help
```
These commands parse the harness, run its pure/static safety tests, and print help. They do not contact the application, database, or either wallet.
These parse the harness, run its pure and static safety tests, and print help. They contact no
application, database, or wallet.
+236 -211
View File
@@ -1,62 +1,85 @@
# Tavern & Multiplayer — Design & Build Plan
# Tavern and Multiplayer
Match mode has its own dedicated design doc: see `docs/MATCH_MODE.md`.
The Tavern is a shared social room; Match mode is a shared competitive dungeon. Both are built on
the same room engine, both are opt-in per instance, and neither affects the single-player path when
disabled.
**Status:** Tavern and server-selected multiplayer rulesets (`race`, `last-alive`, `score-attack`,
`coop-escape`) are implemented (see `docs/MATCH_MODE.md`). Both surfaces are disabled by default
and gated by config, so existing single-player play is unaffected.
Match rules, timing, fairness, and settlement have their own reference: `docs/MATCH_MODE.md`.
Ruleset definitions are documented in `docs/RULESETS.md`.
## Shared engine: one Room, two modes
Both modes are built on a single shared component — a real-time, multi-occupant `Room`:
Both surfaces sit on a real-time, multi-occupant `Room`:
- **Tavern** — a peaceful Room: players pick an avatar, walk around, chat, and watch live games.
- **Multiplayer** — the same Room engine with a dungeon, monsters, and ruleset-selected
collision/combat behavior.
- **Tavern**: a peaceful room. Players pick an avatar, walk around, chat, and watch live games.
- **Match**: the same room engine with a dungeon, a monster, and ruleset-selected collision and
combat behavior.
The engine (`src/multiplayer/`) has no Socket.IO, database, or Wownerogue coupling. A manager
The engine under `src/multiplayer/` has no Socket.IO, database, or application coupling. A manager
layer owns transport (Socket.IO rooms, broadcasts) and the server-tick timer. Keeping the engine
transport-agnostic makes it unit-testable in isolation and reusable as a standalone package.
## Modes
`src/multiplayer/`:
**Tavern** — a social space that reuses the existing lobby and spectator infrastructure. Players
choose an avatar, move around a shared map, and chat. A spectator camera lets them watch live
single-player or multiplayer games from inside the room.
- `Occupant.js`: a shared-world avatar (position, name, avatar id, facing).
- `Room.js`: map and walkability, server-authoritative one-tile movement, optional occupant
collision, a `tick()` hook for autonomous systems (a heartbeat in the Tavern), and
snapshot/full-state serialization.
- `tavernMap.js`: the default tavern layout. Rows are built to a uniform width, and one legend
drives both client tiles (`sceneModel` `TAVERN_LEGEND`/`TILE_OF`) and `Room` walkability, so the
two cannot disagree about what is solid. Walkable characters are floor, spawn, rug, chair, and
door; everything else blocks, which is what makes players weave between furniture.
- `appearance.js`: the cosmetic identity model and its normalizer.
- `entitlements.js`: the server-side entitlement policy.
- `MatchRoom.js`, `MatchState.js`, `MatchEngine.js`: match world, state, and lifecycle.
## Tavern
A social space that reuses the lobby and spectator infrastructure. Players choose an avatar, move
around a shared map, and chat. A spectator camera lets them watch live single-player or match games
from inside the room, and a queue button lets them enter a match.
### Entering anonymously
The Tavern requires no account or login. On connection, the browser registers the client and waits
for the normal anonymous session handshake (`session_token`, `session_resumed`, or the associated
identity response) before enabling **Enter tavern**. The token is stored as `wownerogue_token` in
that browser's `localStorage` and is reused after refresh or reconnect.
The Tavern requires no account or login. On connection the browser registers the client and waits
for the anonymous session handshake (`session_token`, `session_resumed`, or the associated identity
response) before enabling **Enter tavern**. The token is stored as `wownerogue_token` in that
browser's `localStorage` and reused after refresh or reconnect.
The join form accepts its submit action, including Enter from the optional name field. Its button
shows `Connecting…`, `Preparing…`, or `Entering…` while the corresponding step is pending. A
reads `Connecting…`, `Preparing…`, or `Entering…` while the corresponding step is pending. A
disconnect or connection error resets readiness and retries automatically; a join with no server
response becomes a visible retryable error after ten seconds. Cosmetic availability is still
normalized and enforced by server entitlement policy.
response becomes a visible retryable error after ten seconds. Cosmetic selections are normalized and
enforced by server entitlement policy regardless of what the client sends.
**Multiplayer Match** — a shared dungeon Room using the operator-selected ruleset. Players may race
to escape, fight to remain last alive, compete for score, or cooperate. Block-bounded rulesets end
on the first advancing header after their active-play duration floor; every match also has a hard
ceiling. Match rooms enable the collision/combat rules selected by the ruleset; the Tavern does not.
## Match
## Operator Policy
A shared dungeon room using the operator-selected ruleset. Depending on the ruleset, players race to
escape, fight to remain last alive, compete for score, or cooperate. Block-bounded rulesets end on
the first advancing block header after their active-play duration floor (`ruleset.timing.minDurationMs`),
and every match also has a hard ceiling. Match rooms enable the collision and combat rules the
ruleset selects; the Tavern does not.
The server operator controls which modes are enabled and, for multiplayer, the economic model.
Mode activation, the multiplayer ruleset, and multiplayer economy switches are startup environment
configuration. Change `SOLO_ENABLED`, `TAVERN_ENABLED`, `MATCH_ENABLED`, `MATCH_RULESET_ID`, or the
`MATCH_*` economy settings in the deployment configuration and restart the service. The separate
database-backed `ConfigPersistence` allowlist covers a limited set of solo payment/difficulty
values; it does not hot-reload match modes or rulesets. There is no per-room ruleset editor today.
Built-in rulesets are `race`, `last-alive`, `score-attack`, and `coop-escape`. A `solo-classic`
descriptor exists for catalog parity; the single-player engine does not consume it.
Which modes are enabled is surfaced to clients in the `game_mode_info` event as
`modes: { solo, tavern, match }` (from `SOLO_ENABLED`, `TAVERN_ENABLED`, and `MATCH_ENABLED`).
The client shows or hides entry points from this, so any single mode can run on its own — including
a Tavern-only instance. Solo is on unless explicitly disabled; Tavern and Match are opt-in.
## Operator policy
Multiplayer economic models (operator-selected per instance):
The operator controls which modes are enabled and, for match play, the economic model. Mode
activation, the ruleset, and match economy switches are startup environment configuration: set
`SOLO_ENABLED`, `TAVERN_ENABLED`, `MATCH_ENABLED`, `MATCH_RULESET_ID`, or the `MATCH_*` economy
settings and restart the service. The server reads them once at startup:
it does not hot-reload match modes or rulesets, and there is no per-room
ruleset editor. The full environment template is `src/.env.example`.
Enabled modes are surfaced to clients in `game_mode_info` as
`modes: { solo, tavern, multiplayer, match }`. `solo`, `tavern`, and `multiplayer` are booleans;
`match` is an object carrying `enabled`, the available `economies`, `maxPlayers`, the
`activeRuleset`, and the full ruleset catalog. The client shows or hides entry points from this, so
any single mode can run on its own, including a Tavern-only instance. Solo is on unless explicitly
disabled; Tavern and Match are opt-in.
Match economic models:
| Model | Description |
|---|---|
@@ -64,62 +87,81 @@ Multiplayer economic models (operator-selected per instance):
| `credits_prestige` | Spend credits; competitive results use the separate PvP Prestige board, with no crypto payout. |
| `crypto_race` | Use a backed race-entry ticket; an eligible competitive winner receives the disclosed pot less the configured fee. |
The baseline defaults are conservative (free, no payout). Instance deployment configuration may
enable additional economies; operators are responsible for ensuring their selected configuration
complies with applicable laws in their jurisdiction.
`free` is always offered. `credits_prestige` requires credits mode or free play to be enabled.
`crypto_race` additionally requires its own switch, a payout cap, a compatible ruleset, and the
global payout safety gate, all evaluated by `matchPayoutAdmissionPolicy`. Baseline defaults are
conservative (free, no payout). Operators are responsible for ensuring their selected configuration
complies with applicable law in their jurisdiction.
## Avatars
## Character appearance contract
Avatars are cosmetic. Availability is controlled by Operator Policy — for example, a set available
to everyone plus additional avatars unlocked by credit purchases. The avatar id is stored on the
occupant and broadcast to the room; unlock eligibility is enforced at join time.
The public identity shape is `appearance: { avatar, tint, equipment, colors }`. The server
normalizes this shape, persists it on `users.appearance`, stores it on the `Occupant` at join time,
and broadcasts it in room snapshots. Renderers use `appearance` as the canonical cosmetic identity;
the flat `avatar` field remains as a compatibility shortcut and as the first discriminator for
sprite and skin loading.
## Character Appearance Contract
Normalization in `src/multiplayer/appearance.js` is total: an unknown avatar id collapses to
`default`, unknown equipment collapses to `none` per slot, and unknown tints, skin tones, and hair
colors collapse to their defaults. Flat colour avatars carry no equipment or colour detail; 3D model
avatars carry colours but no 2D equipment overlays; character avatars carry both.
The public identity shape is `appearance: { avatar, tint, equipment }`. The server normalizes this
shape, persists it on `users.appearance`, stores it on the `Occupant` at join time, and broadcasts
it in room snapshots. Renderers use `appearance` as the canonical cosmetic identity; the flat
`avatar` field remains as a compatibility shortcut and as the first discriminator for sprite/skin
loading.
The contract is renderer-agnostic:
This contract is intentionally renderer-agnostic:
- **Top-down grid packs** resolve `avatar` to a base tile or frame and composite `equipment`
overlays above it. `tint` recolours only the authored recolour pixels, so outlines, skin, wood,
and metal stay intact.
- **Isometric packs** keep the same `appearance` ids and supply an isometric resolver for base
frames, direction rows, anchor position, and equipment overlays. A missing item falls back by role
(`round_shield` to `shield` to `none`) rather than changing the saved identity.
- **3D packs** resolve the same ids to GLB assets, material variants, and animation clips. Source
models and intermediates stay out of git; shipped GLB or baked sprite outputs are hosted and
lazy-loaded like premium sprite sheets.
- **Top-down grid packs** resolve `avatar` to a base tile/frame and composite `equipment` overlays
above it. `tint` recolours only the authored recolour pixels so outlines, skin, wood, and metal
remain intact.
- **Isometric packs** should keep the same `appearance` ids and supply an isometric resolver for
base frames, direction rows, anchor/feet position, and equipment overlays. If an exact item is
missing, the resolver should fall back by role (`round_shield -> shield -> none`) rather than
changing the saved identity.
- **True 3D packs** should resolve the same ids to GLB assets/material variants/animation clips.
Source models and intermediates stay out of git; shipped GLB or baked sprite outputs are hosted
and lazy-loaded like premium sprite sheets.
The client may hide or label locked choices, but gating belongs at the server policy layer: identity
saves and join requests normalize unauthorized premium avatar ids to an allowed fallback.
Premium costumes are catalog entries with an entitlement requirement. The client may hide or label
locked choices, but real gating belongs at the server/operator policy layer: identity saves and
join requests normalize unauthorized premium `avatar` ids to an allowed fallback.
## Entitlements and the cosmetic catalog
Current baseline premium policy: a player is treated as premium after any successful credits
purchase (`users.total_credits_purchased > 0`). That unlocks the preview premium packs
(`generated-skins`, `iso-dungeon`, and `kenney-3d-characters`) unless a future operator policy
tightens them. The policy shape is already pack-based so products can sell or grant different packs
independently. Operator/admin grants go through
`user_pack_entitlements(user_id, pack_id, source, expires_at, metadata)`, while
`users.premium_level` is reserved for broader tiering.
`src/multiplayer/entitlements.js` is the single policy module used by identity saves, tavern joins,
render-mode gating, and payment confirmation. In production the operator-owned `cosmetic_catalog`
table (migration 024) is loaded by `CatalogService` and passed in; the `DEFAULT_CATALOG` in the
module is the fallback for fresh or partly migrated databases and for unit tests.
## Product Grants
Each catalog entry is `{ id, label, kind, projection, tier, unlockMinCredits, grantOnly, premium }`.
A pack is unlocked when any of the following holds:
- it is a free pack (tier 0, no credit threshold, not grant-only);
- the user holds an explicit grant for it in `user_pack_entitlements`;
- the user's lifetime credits purchased (`users.total_credits_purchased`, cumulative and never
decremented by play) is at or above the pack's `unlockMinCredits`;
- the user's premium tier is at or above the pack's tier.
Premium tier comes from `users.premium_level` or an active subscription tier, whichever is higher,
over the ladder `supporter` (1), `premium` (2), `operator` (3). Buying credits does not by itself
place a user on the tier ladder: `credits` maps to tier 0, and credit spend unlocks packs only
through the `unlockMinCredits` thresholds.
The seed ladder is the free `original` tile pack plus premium packs at rising lifetime-credit
thresholds: Roguelike Interior at 1, character skins at 5, isometric dungeon at 10, roguelike
dungeon tiles at 20, isometric medieval at 40, and animated 3D characters at 50. Adding a rung is a
catalog row plus assets, not a code change. See `docs/MONETIZATION.md` and `docs/RENDER_PACKS.md`.
`snapshotForUser()` returns `{ premium, level, tier, credits, totalCreditsPurchased, packs, catalog }`.
The `packs` map is authoritative for gating; `canUsePack` consults it first and only falls back to
the coarse `premium` flag when a caller supplies a legacy entitlement object without one.
## Product grants
Payments stay separated from entitlement policy:
- **Single-game entries** buy one paid game attempt and do not grant premium by default.
- **Credits packages** grant credits and may bundle cosmetic/render packs or a premium tier.
- **Single-game entries** buy one paid game attempt and grant no packs by default.
- **Credits packages** grant credits and may bundle cosmetic packs or a premium tier.
- **Cosmetic products** grant packs or tiers directly and may grant zero credits.
Catalog configuration supports `grants` on credit packages and a separate `COSMETIC_PRODUCTS`
catalog. A grant payload is normalized to:
`{ credits, packs: [{ id, expiresAt, source }], premiumLevel }`.
Examples:
catalog (see `src/.env.example`). A grant payload normalizes to
`{ credits, packs: [{ id, expiresAt, source }], premiumLevel }`, and unknown pack ids are dropped.
```json
{
@@ -146,168 +188,151 @@ Examples:
}
```
Confirmed product payments persist the normalized grant on `payments.product_grants`, update
credit balances only when credits are part of the product, and upsert pack ownership in
Confirmed product payments persist the normalized grant on `payments.product_grants`, update credit
balances only when credits are part of the product, and upsert pack ownership in
`user_pack_entitlements`. The client receives the updated entitlement snapshot through
`credits_update` / `identity_update`, so the same premium state gates character saves, tavern joins,
render modes, and later multiplayer rooms.
`credits_update` and `identity_update`, so the same state gates character saves, tavern joins,
render modes, and match rooms.
Supporting pieces:
- `src/migrations/020_user_appearance_and_pack_entitlements.sql`: persisted appearance, premium
tier, and pack grants.
- `src/migrations/021_payment_product_grants.sql`: product ids and normalized grants on payments.
- `src/migrations/024_cosmetic_catalog.sql` and the ladder migrations that follow it: the
operator-owned catalog.
- `src/network/identityService.js`: appearance persistence and entitlement-aware normalization.
- `src/payments/productGrants.js`: grant normalization and public summaries.
## Chat
The existing chat backend is retained and wrapped behind a `ChatProvider` interface so the backend
can be swapped later without changing callers. The current backend remains the default.
The chat backend sits behind a `ChatProvider` interface (`src/network/chat/`) so it can be swapped
without changing callers. `SocketChatProvider` is the default: Socket.IO delivery plus Postgres
history. `buildChatProvider()` returns the plain local provider unless `NOSTR_CHAT_ENABLED` is set
and `NOSTR_CHAT_SCOPE` is `global`, in which case `NostrChatProvider` layers cross-server relay fan-out
over it. See `docs/CHAT_AND_NOSTR.md`.
**Tavern chat is global chat.** When a `globalChatProvider` is injected, `tavern_chat` reaches every
connected client and the persisted history, so it must clear the same bar as the lobby path:
`TavernManager._moderateChat` applies the chat ban list, the reconnect-proof rate limiter
(`stableId` + IP), and `user_id` attribution before publishing. Without those guards `tavern_chat` is
simply an unauthenticated bypass of lobby moderation under a client-chosen display name. The
per-occupant cooldown alone still governs the ephemeral tavern-scoped fallback room, which nobody
outside the room ever sees.
**Tavern chat is global chat.** When a global chat provider is injected, `tavern_chat` publishes to
the global scope, reaching every connected client and the persisted history, so it must clear the
same bar as the lobby path. `TavernManager._moderateChat` applies the chat ban list, the
reconnect-proof rate limiter (keyed on the stable session id plus IP), and `user_id` attribution
before publishing. Without those guards `tavern_chat` would be an unauthenticated bypass of lobby
moderation under a client-chosen display name. When no global provider is injected, messages stay in
the ephemeral tavern-scoped room that nobody outside it sees, and the per-occupant cooldown alone
governs them.
Message text is escaped at publish time because delivery is trusted-escaped and rendered as HTML on
the client.
Speech bubbles resolve the speaker from the occupant list **by display name**. The chat provider
publishes a short `publicId`, never the raw socket id, so anything keying bubbles off `socketId`
silently never fires.
never fires.
## Chain ambience
The Tavern stage carries a decorative chain layer (`RK.mountChainAmbience`, `chainAmbience.js`):
fragments of the real top block hash fade in around the border and drift away, a corner readout
tracks the height, and a landing block sends a ripple around the frame plus a brighter burst.
fragments of the top block hash fade in around the border and drift away, a corner readout tracks
the height, and a landing block sends a ripple around the frame plus a brighter burst.
It consumes the public `blockheight` broadcast, which now carries an optional cosmetic tip
(`hash`, `difficulty`, `txPoolSize`). The tip is refreshed from `get_info` **only when the height
actually advances** — it is decoration, not worth an extra RPC every poll — and
`RpcService.getChainTipInfo()` never throws and never feeds fairness, payouts, or match seeding.
`DebugManager.getChainTip()` returns null under simulated blocks, since there is no chain to
describe. The layer is `pointer-events:none`, honours `prefers-reduced-motion`, and stops spawning
(and drops its backlog) while the tab is hidden — a hidden tab pauses CSS animations, so
`animationend` never fires and motes would otherwise accumulate indefinitely.
It consumes the public `blockheight` broadcast, which carries an optional cosmetic tip (`hash`,
`difficulty`, `txPoolSize`). The tip is refreshed from `get_info` only when the height actually
advances, since it is decoration and not worth an extra RPC per poll.
`RpcService.getChainTipInfo()` never throws and never feeds fairness, payouts, or match seeding, and
`DebugManager.getChainTip()` returns null under simulated blocks because there is no chain to
describe.
## Rendering (art pass)
The layer is `pointer-events: none`, marked `aria-hidden` with the corner readout as its accessible
copy, honours `prefers-reduced-motion`, and stops spawning while the tab is hidden. A hidden tab
pauses CSS animations, so `animationend` never fires and motes would otherwise accumulate
indefinitely; the backlog is dropped on hide.
Rendering goes through a shared, renderer-agnostic **scene model**: game/tavern state is adapted
into a `Scene` (a tile grid + entities), and any renderer draws it. This lives in
`html/js/render/` and is used by the Tavern, multiplayer client, spectators, and the single-player
render bridge.
## Rendering
Selectable render techniques:
- **ASCII** — monospace glyph grid (canvas). Always-available fallback; accessible.
- **Tiled** — coloured tiles + entity sprites (canvas 2D). The default. Real tilesets/atlases
slot in here later.
- **Iso** — canvas isometric projection using Kenney's Isometric Miniature Dungeon runtime PNGs.
- **3D** — Three.js projection, loaded lazily from the pinned same-origin `three@0.160.0` runtime.
It attaches generated GLB avatars with `idle` / `run` / `jump` clips when available and keeps a
lightweight low-poly fallback only when the model cannot load.
Rendering goes through a renderer-agnostic **scene model**: game or tavern state is adapted into a
`Scene` (a tile grid plus entities), and any renderer draws it. This lives in `html/js/render/` and
serves the Tavern, the match client, spectators, and the single-player render bridge.
`fancyRenderer.js` remains an internal PixiJS prototype, not a registered selectable mode.
Production top-down styling comes from interchangeable packs plus the shared canvas FX layer.
Planned: **Fancy ASCII** — shader-lit, animated glyphs.
Selectable render techniques (`RK.RENDER_MODES`):
Render mode is a per-user choice (persisted) with an operator default. Modes can be marked
**premium** — a cosmetic entitlement intended to be unlocked with credits (the same Operator
Policy pattern as avatar unlocks). Because rendering is client-side, this is a soft/cosmetic gate,
not a hard boundary; premium-only *assets* (special tilesets, shaders) are the real lever if
stronger gating is wanted. The render kit provides the engine and programmatic tiles/effects —
polished pixel-art tilesets, sprite sheets, and shader effects are a separate content effort.
| Mode | Implementation | Premium |
|---|---|---|
| `tiles` | Coloured tiles and entity sprites on canvas 2D. The default. | No |
| `ascii` | Monospace glyph grid on canvas. Always-available fallback; accessible. | No |
| `iso` | Canvas isometric projection using the isometric dungeon pack art. | Yes |
| `3d` | Three.js projection with generated GLB avatars (`idle`, `run`, `jump` clips) and a low-poly fallback when a model cannot load. | Yes |
**CSP note:** If the internal PixiJS prototype is invoked, it loads `@pixi/unsafe-eval`
immediately after `pixi.js` to precompile shaders without adding `'unsafe-eval'` to CSP. It also
requires the server's explicit external-renderer policy; no current mode button invokes it.
A premium mode unlocks once the user holds any unlocked pack for that mode's projection, so
entitlement is expressed once in the catalog rather than duplicated per mode. Render mode is a
per-user persisted choice with an operator default. Because rendering is client-side this is a
cosmetic gate; premium-only *assets* are the enforceable lever.
## Asset delivery & performance
`fancyRenderer.js` is an internal PixiJS prototype, not a registered selectable mode. No mode button
invokes it. If it is invoked directly it loads `@pixi/unsafe-eval` immediately after `pixi.js` so
shaders precompile without adding `'unsafe-eval'` to the CSP, and it requires the server's external
renderer policy to be on.
Rules for keeping the tree lean and the client fast:
Production top-down styling comes from interchangeable packs plus the shared canvas FX layer. The
render kit provides the engine and programmatic tiles and effects; authored tilesets, sprite sheets,
and shader effects are a separate content effort.
- **Heavy source artifacts never go in git.** 3D models and generation intermediates (`*.glb`,
`*.fbx`, `*.blend`, …) are produced by the sprite pipeline (`~/src/docs/animated-sprite-pipeline.md`)
and are gitignored. Only the *outputs* the game loads are considered for shipping.
- **Generated / premium assets are provisioned, not committed.** Premium skins and runtime GLBs
live under `html/assets/generated/` (gitignored). They may be provisioned into the same-origin
runtime path or delivered by an explicitly reviewed asset host and CSP policy; they are not
present in the Git release artifact. The base tier's small Kenney CC0 tiles stay in-repo.
- **Lazy-load by mode / entitlement.** Nothing heavy loads until it's actually needed:
- ASCII and Tiled need no WebGL library. The unregistered Fancy prototype requests PixiJS plus
`@pixi/unsafe-eval` only if called directly.
- A premium skin's sprite sheet is fetched only when that skin is selected and the player is
entitled to it.
- The exact production Three.js dependency is served beneath `/vendor/three/0.160.0/` and
imported only when **3D** is selected. Local delivery takes precedence; external renderer
execution remains off unless `RENDERER_CDN_ENABLED=true`.
- Generated GLBs are emitted by `scripts/build_kenney_3d_characters.py` from local Kenney FBX
sources into `html/assets/generated/3d/` (gitignored).
- **Optimize what ships.** Sprite sheets are delivered as **WebP** (alpha), right-sized to display
resolution, content-hashed with a long cache TTL. (Example: the demo walk sheet is 633 KB as PNG,
65 KB as WebP — ~10×.)
## Asset delivery and performance
## Milestones
- **Heavy source artifacts are not committed.** 3D models and generation intermediates (`*.glb`,
`*.fbx`, `*.blend`) are gitignored. Only the outputs the game loads are candidates for shipping.
- **Generated and premium assets are provisioned, not committed.** Premium skins and runtime GLBs
live under `html/assets/generated/`, which is gitignored. They are provisioned into the
same-origin runtime path or delivered by an explicitly reviewed asset host and CSP policy, and are
not present in the Git release artifact. The base tier's small Kenney CC0 tiles stay in-repo.
- **Load by mode and entitlement.** ASCII and Tiled need no WebGL library. A premium skin's sprite
sheet is fetched only when that skin is selected and the player is entitled to it. Generated GLBs
are emitted by `scripts/build_kenney_3d_characters.py` from local Kenney FBX sources into
`html/assets/generated/3d/`.
- **Three.js is served from this origin.** `src/index.js` publishes the browser build and addons
under `/vendor/three/0.160.0/` from the pinned `three` dependency, with immutable versioned URLs
so a cached copy cannot survive an upgrade to different bytes. Pages declare it through an import
map, and `RK.ensureThree` imports it only when 3D is selected. `RENDERER_CDN_ENABLED` defaults to
false: third-party renderer code would execute with the same privileges as the game and payment
UI, so trusting a CDN is an explicit operator decision and widens the CSP when enabled.
- **Optimize what ships.** Sprite sheets are delivered as WebP with alpha, sized to display
resolution, content-hashed with a long cache TTL. The demo walk sheet is roughly ten times smaller
as WebP than as PNG.
Tavern:
- **T0** — Shared engine scaffold: `Room`, `Occupant`, tavern map, unit tests. (Done)
- **T1** — `TavernManager`: Socket.IO room, server-tick timer, join/move/leave broadcasts; minimal
client (`html/tavern.html`) rendering avatars; gated behind `TAVERN_ENABLED`. (Done)
- **T2a** — `ChatProvider` seam + tavern chat. (Done)
- **T2b** — Spectator camera into live solo and multiplayer games from the Tavern. (Done)
- **T3** — Avatar picker with policy-gated unlocks. (Done)
## Client and transport
Multiplayer:
- **M0** — Match Room with a dungeon, shared monster, server ticks, and bounded lifecycle. (Done)
- **M1** — Player-vs-player combat for the `last-alive` ruleset. (Done)
- **M2** — Environmental hazards.
- **M3** — Free, credit-prestige, and gated crypto-race economies wired in. (Done)
`src/network/tavernManager.js` owns transport and lifecycle: one shared `Room`, the server-tick
timer (default 100ms, override with `TAVERN_TICK_MS`), and Socket.IO room broadcasts on the
`tavern:<roomId>` channel. It is wired into `SocketHandlers` (`tavern_join`, `tavern_move`,
`tavern_leave`, plus disconnect cleanup and shutdown) and is inert unless `TAVERN_ENABLED=true`.
Rendering:
- **R0** — Shared render kit: scene model + selectable ASCII / Tiled / Iso / 3D techniques,
internal Fancy prototype, and a mode switch wired to the Tavern. Premium-mode gating
scaffolded. (Done)
- **R1** — Wire the main game to the render kit behind the existing display layer. (Done)
- **R2** — Real tilesets / sprite sheets and the Fancy-ASCII tier.
- **R3** — Wire premium render modes to credit entitlements (Operator Policy). (Done)
`src/network/tavernMatchBridge.js` relays match progress into the Tavern (`tavern_match_tick`,
`tavern_match_end`) and requires both `TAVERN_ENABLED` and `MATCH_ENABLED`.
All milestones are additive and config-gated; the single-player path is unchanged.
`html/tavern.html` is the browser client: establish or resume an anonymous session, choose an
optional name and avatar, enter the room, walk with keyboard or on-screen controls, chat, watch solo
or match games, or join a match queue. No account login is required.
## Current status
`html/js/render/` is the render kit: `sceneModel.js` (renderer-agnostic adapters), the ASCII, tiled,
isometric, and Three.js renderers, the internal Fancy prototype, and `renderModes.js` (registry,
factory with graceful fallback, entitlements, persistence). Clients expose a mode toolbar where
applicable; premium modes are marked and gated by `RK.entitlements`.
`src/multiplayer/`
- `Occupant.js` — a shared-world avatar (position, name, avatar id, facing).
- `Room.js` — map/walkability, server-authoritative one-tile movement, optional occupant
collision, a `tick()` hook for future autonomous systems, and snapshot/full-state serialization.
- `tavernMap.js` — a procedurally generated (rectangular) default tavern layout.
## Tests
`src/network/tavernManager.js` — transport + lifecycle: a shared Room, the server-tick timer,
and Socket.IO room broadcasts. Wired into `SocketHandlers` (`tavern_join` / `tavern_move` /
`tavern_leave`, plus disconnect cleanup and shutdown). Inert unless `TAVERN_ENABLED=true`.
- `test/tavernRoom.test.js`: the room engine.
- `test/tavernManager.test.js`: the manager, including chat moderation.
- `test/tavernMatchBridge.test.js`: match-to-tavern relay.
- `test/chatProvider.test.js`: the provider seam.
- `test/renderScene.test.js`: the scene adapter.
- `test/entitlements.test.js`, `test/catalogEntitlements_ultra.test.js`: entitlement policy and the
operator catalog.
- `test/identityService.test.js`, `test/appearance.test.js`, `test/appearanceModel3d.test.js`:
appearance normalization and persistence.
- `test/productGrants.test.js`: grant normalization.
- `test/pvpUiPolish.test.js`: anonymous readiness and join states.
- `test/rendererCdnPolicy.test.js`: the same-origin Three.js runtime policy.
`html/tavern.html` — the browser client: establish/resume an anonymous session, choose an optional
name/avatar, enter the room, walk with keyboard or on-screen controls, chat, watch
solo/multiplayer games, or join a match. No account login is required.
## Running locally
`src/network/chat/` — the `ChatProvider` seam. `ChatProvider` (interface) + `SocketChatProvider`
(default: Socket.IO delivery + Postgres history). Both the global chat (`ChatHandler`) and tavern
chat deliver through it, so the backend can be swapped (e.g. a Nostr channel) without changing
callers. Tavern chat is room-scoped and ephemeral.
`html/js/render/` — the render kit: `sceneModel.js` (renderer-agnostic adapters), ASCII, tiled,
isometric, and Three.js renderers plus the internal Fancy (PixiJS) prototype, and
`renderModes.js` (registry, factory with graceful fallback, entitlements, persistence). Clients
expose a mode toolbar where applicable; premium modes are marked and gated by `RK.entitlements`.
Tests: `test/tavernRoom.test.js` (engine), `test/tavernManager.test.js` (manager incl. chat),
`test/chatProvider.test.js`, `test/renderScene.test.js` (scene adapter),
`test/entitlements.test.js`, `test/identityService.test.js`, and `test/productGrants.test.js`.
`test/pvpUiPolish.test.js` covers the anonymous readiness/join states, and
`test/rendererCdnPolicy.test.js` covers the same-origin Three.js runtime policy. End-to-end
join/move/leave, local 3D, and chat over a real socket are verified out-of-band.
Payment/product entitlement pieces:
- `src/migrations/020_user_appearance_and_pack_entitlements.sql` — persisted appearance,
premium tier, and pack grants.
- `src/migrations/021_payment_product_grants.sql` — product ids and normalized grants on payments.
- `src/network/identityService.js` — server-side appearance persistence and entitlement-aware
normalization.
- `src/payments/productGrants.js` — product grant normalization/public summaries.
- `src/multiplayer/entitlements.js` — server policy snapshot used by identity, tavern joins, and
payment confirmations.
Enable locally with `TAVERN_ENABLED=true` (optionally `TAVERN_TICK_MS`), then open `/tavern.html`
in two browser tabs.
Set `TAVERN_ENABLED=true` (optionally `TAVERN_TICK_MS`), start the server, and open `/tavern.html`
in two browser tabs. Add `MATCH_ENABLED=true` and a `MATCH_RULESET_ID` to exercise match entry from
the Tavern.
-188
View File
@@ -1,188 +0,0 @@
## Match Mode (Complete)
All milestones implemented. See `docs/MATCH_MODE.md` for architecture and configuration.
- [x] Design review: economy models, block-cadence lifecycle, ticket system, premium integration
- [x] Database schema: `matches`, `match_entrants`, `match_events`, `match_queue_entries`, `race_entry_transactions`, `users.race_entries`, `payouts.match_id`
- [x] Engine scaffold: `MatchRoom`, `MatchEngine`, `MatchState` with unit tests
- [x] Per-economy queues and block-cadence `MatchScheduler`
- [x] Socket.IO `MatchManager` and free races
- [x] Credits/prestige races and separate Prestige leaderboard
- [x] Crypto race-entry tickets and winner-take-pot payout
- [x] Client (`match.html`, `matchClient.js`) and Tavern spectator integration
- [x] Reconnect/AFK tests
- [x] Final doc pass and operator deployment notes
- [x] Unit tests: 40 passing across 7 suites
# 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).
- [x] **Block-bounded pot race** (flagship): `Ruleset.timing.blockDeadline` expires active matches
on the first advancing header after the play-time floor; winner-take-pot-minus-fee admission
is durable and co-op is excluded until split-payout semantics exist.
- [x] **PvP wiring**: the server selects `race`, `last-alive`, `score-attack`, or `coop-escape`
with `MATCH_RULESET_ID`; match state and persistence record the active ruleset.
- [ ] **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 ✅
- [x] **Test Payouts**: Payout flow verified with duplicate prevention and proper mode tracking ✅
- [x] **Address Persistence**: Verify that payout addresses persist across server restarts via session tokens. ✅
- [x] **Mixed Mode Logic**: Per-user mode determination based on credits balance ✅
- [x] **Credits Package Bug**: Credits correctly parsed from package info on confirmation ✅
- [x] **Stagenet Support**: Added MONERO_NETWORK config with UI warnings for non-mainnet ✅
- [x] **Difficulty Tuning**: Configurable presets (easy/normal/hard/casino) for house edge control ✅
- [x] **Provably Fair**: SHA-256 commitment scheme for verifiable game generation ✅
## Recent Fixes (Jan 2026)
- [x] **Payment DB Status Bug**: Fixed critical issue where single_game payments weren't marked 'confirmed' in DB, causing game start to fail ✅
- [x] **TileMap Character Error**: Fixed '●' character not in tileMap causing tile mode rendering failures ✅
- [x] **Test Suite Fixes**: All 106 tests now pass (10 test suites) ✅
- [x] **Credit Deduction Bug**: Fixed race condition where credits could go negative - now uses `WHERE credits >= $1` ✅
- [x] **Payout Mode Bug**: Fixed `completeGame` using global `gameMode` instead of recorded `payment_mode` from game record ✅
- [x] **Duplicate Payout Prevention**: Added check for existing payout before processing to prevent double-payouts ✅
- [x] **Dead Code Cleanup**: Removed unused MoneroPayService (was never used, WalletRPCService is the actual service) ✅
- [x] **Game Reconnection/Resume**: Added `SuspendedGameManager` to preserve and restore games when users disconnect and reconnect. Users in dungeon or awaiting payment can now resume seamlessly ✅
## New Features (Just Added)
- [x] **Chat History Persistence**: `ChatHistoryManager` stores messages in PostgreSQL with 30-day retention ✅
- [x] **Chat History on Connect**: New users receive last 50 messages on connect via `chat_history` event ✅
- [x] **Spectator System**: Full spectator mode with `SpectatorManager` for watching live games ✅
- [x] **Active Games List**: Real-time game list with pagination, sorting, and auto-refresh ✅
- [x] **Spectate UI**: "Watch Games" button, live game panel, and spectator controls ✅
- [x] **Spectator Broadcasts**: Game updates broadcast to spectators via Socket.IO rooms ✅
- [x] **Help Modal**: Added instructions modal with game rules, controls, and payment mode info ✅
- [x] **Dynamic Treasure Icon**: Treasure tile now shows $W (Wownero) or $M (Monero) based on crypto type ✅
- [x] **Address Status Indicator**: Payout address button shows warning when no address is set ✅
## User Feedback (Beta Tester - Dec 2025)
- [ ] **History Panel Bug**: User reported payout not showing in History after winning with treasure (needs investigation - may be timing/status issue with "confirmed" vs "pending" payouts)
- [x] **Early Dungeon Entry**: Allow credits/free mode users to enter dungeon immediately without waiting for next block (risky - they die if block found before escape). Toggleable via config. ✅
## Improvements / Refactoring
- [x] **Error Handling**: Extended `normalizeError` to queueHandler, queueManager, sessionManager ✅
- [x] **Configuration Persistence**: Added `configPersistence.js` for hot-reloadable DB-backed config ✅
- [x] **Monitoring Dashboard**: Created `/admin.html` with live metrics (games, queue, memory, wallet) ✅
- [x] **Frontend Cleanup**: Removed rot.js (using minified), game1.mid (unused), .DS_Store, moved tiles.xcf ✅
- [x] **Provably Fair UI**: Hash commitment shown at game start, seed revealed with verification link at game end ✅
- [x] **Verification Endpoint**: `/verify/:gameId` HTML page + `/api/verify` JSON endpoint for public verification ✅
## Documentation
- [x] **API Documentation**: README updated with accurate API surface including health check and Socket.IO events ✅
- [x] **Session Persistence**: README documents session token behavior and localStorage requirements ✅
- [x] **Difficulty System**: README documents difficulty presets and house edge tuning ✅
- [x] **Provably Fair**: README documents cryptographic verification system ✅
- [x] **Deployment Guide**: README includes production deployment with Nginx/Caddy configs and systemd ✅
## Pre-Beta Checklist
- [x] All 106 tests pass (10 test suites) ✅
- [x] Health check endpoint available at `/health` ✅
- [x] REST endpoints implemented: `/api/user/:socketId/credits`, `/api/user/:socketId/mode`, `/api/user/:socketId/address` ✅
- [x] Payout eligibility shown per payment mode in UI ✅
- [x] Session persistence tooltip and localStorage warning added ✅
- [x] Stagenet/testnet warning banner for Monero non-mainnet ✅
- [x] Chat history persisted and sent to new users ✅
- [x] Spectator system functional with live game viewing ✅
- [ ] **TLS/HTTPS**: Configure reverse proxy (Nginx/Caddy) with SSL certificates before exposing to real users.
- [x] **Database Backups**: `scripts/backup_db.sh` + systemd timer for daily backups with 30-day retention (see `LOGS_AND_BACKUP.md`) ✅
- [ ] **Manual Integration Test**: Run full payment flow with real `wownero-wallet-rpc` (not mocked).
- [x] **Rate Limit Tuning**: Current defaults (60 games/hour, 100 payouts/day) suitable for launch ✅
- [x] **Log Rotation**: Using systemd journald with configurable retention (see `LOGS_AND_BACKUP.md`) ✅
## Security Audit Fixes (Jan 2026)
- [x] **XSS Vulnerability**: Complete HTML entity escaping in chat handler (`&`, `<`, `>`, `"`, `'`)
- [x] **tx_hash Idempotency**: UNIQUE partial indexes on payments/payouts tables (migration 008)
- [x] **Timing-Safe Auth**: Admin API uses `crypto.timingSafeEqual()` for key comparison
- [x] **Transactional Payouts**: Payout flow creates pending record first, updates atomically
- [x] **Payout Retry Service**: Auto-retries failed payouts (max 3 attempts, 5-min interval)
- [x] **Address Validation**: `validateAddress()` called before every payout attempt
- [x] **Transaction Status Check**: `checkTransactionStatus()` verifies blockchain before retry
## Smirk Wallet Integration (Jan-Feb 2026)
- [x] **Database Schema**: `smirk_public_key` column, `smirk_challenges` table (migration 009)
- [x] **Auth Endpoints**: `/api/auth/smirk/challenge`, `/verify`, `/status`
- [x] **Frontend Module**: `smirkAuth.js` with extension detection and login flow
- [x] **Address Modal**: Shows Smirk connect button or install link
- [x] **Native Payments**: `requestPayment()` integration — Smirk-connected users get auto-prompted via extension instead of manual address/QR. Falls back to normal flow on rejection.
## Admin Dashboard (Jan 2026)
- [x] **Alert Service**: Email alerts via Resend for low balance, disconnects, failed payouts
- [x] **Stats Endpoints**: `/api/admin/stats/overview`, `/payouts`, `/games`, `/users`
- [x] **Admin UI**: Full dashboard at `/admin.html` with charts and tables
- [x] **Payout Retry**: Manual retry button in admin dashboard
## Scoring & Leaderboard (Feb 2026)
- [x] **Scoring System**: New formula (max 800 pts) — base escape + treasure bonus + speed bonus + efficiency bonus
- [x] **Score Persistence**: `score` column on games table, `high_score` on users (migration 014)
- [x] **Leaderboard API**: `GET /api/leaderboard?board=pleb|champions|prestige&period=all|week|month`
with display-name support and no mixed-economy fallback (`board` omitted → Pleb; invalid/legacy
`all` → 400)
- [x] **Leaderboard UI**: Modal with rank, name, best score, wins, games. Period tabs (All/Week/Month)
- [x] **Display Names**: `/nick` chat command stores `display_name` on users table
- [x] **Real-time Updates**: `leaderboard_update` broadcast on game wins, auto-refreshes open modal
- [x] **Difficulty Tuning**: Casino preset hardened — 70x35 dungeon, 1.5x monster speed, 97% chase, 22-tile vision
## Payout Reliability (Feb 2026)
- [x] **Fire-and-Forget INSERT Fix**: `_insertGameRecord` now awaited, prevents race conditions ✅
- [x] **Double Game-Over Prevention**: `activeGames.delete()` moved before any `await` in `handleGameOver` ✅
- [x] **DB-Level Duplicate Payout Prevention**: Unique partial index on `payouts(game_id)` (migration 012) ✅
- [x] **Payout Address Locking**: Address captured at game start, stored in `games.payout_address` ✅
- [x] **Dead Code Removal**: Deleted unused `processGameCompletion()` method ✅
- [x] **Same-Block Payout Batching**: Payouts within 5-second window batched into one `transfer_split` call ✅
- [x] **Output Splitting Script**: `scripts/splitOutputs.js` for wallet output management ✅
- [x] **transfer_split**: Switched from `transfer` to `transfer_split` for output resilience ✅
## Pre-Production Testing Checklist
### Security Verification
- [ ] Chat XSS: `<script>` and `"onclick=` render as plain text
- [ ] Duplicate tx_hash insertion fails with constraint error
- [ ] Payout creates pending record before RPC call
- [ ] Failed payouts have records for retry
- [ ] Retry service processes failed payouts (check logs)
### Smirk Integration Verification
- [ ] "Connect Smirk" button appears when extension installed
- [ ] "Get Smirk Wallet" link appears when extension NOT installed
- [ ] Challenge generated and stored in DB
- [ ] Signature verification links public key to user
- [ ] Payout address auto-populated from wallet
- [ ] Anonymous sessions still work (backwards compatible)
### End-to-End Testing
- [ ] Play full game with Smirk login
- [ ] Win and receive payout to Smirk address
- [ ] Verify payout recorded in transaction history
- [ ] Manual integration test with real wallet-rpc
---
## Known Issues / Future Improvements
### Backend
- [x] **Database Transactions**: `withTransaction()` helper for atomic financial operations ✅
- [ ] **Payment Idempotency**: Replace in-memory Set with database-backed idempotency keys for payment confirmation
- [x] **Payout Retry Queue**: Automatic retry mechanism via `payoutRetryService.js` ✅
- [x] **Address Validation**: Wallet RPC `validate_address` method ✅
- [ ] **Redis Rate Limiting**: Replace in-memory rate limiter with Redis for persistence across restarts
- [ ] **Full Payout Batching**: Implement configurable batch intervals beyond same-block 5-second window
- [ ] **Wallet Health Monitoring**: Track spendable output count, alert when outputs are low
### Frontend
- [ ] **Socket Reconnection**: Add `reconnect` and `disconnect` handlers to restore state after connection loss
- [ ] **Payment Timeout UI**: Show countdown timer for payment expiration, auto-clean expired payment UI
- [ ] **Event Cleanup**: Add proper cleanup for event listeners when modules are re-initialized
- [ ] **Connection Status Indicator**: Show real-time socket connection state in UI
-168
View File
@@ -1,168 +0,0 @@
# Wallet keyfile recovery proof
**Status: source and synthetic tests only. This helper has not been installed on or run against
`deb`, and this document does not authorize a host mutation or a production ceremony.**
This runbook covers one deliberately narrow question: can an audited encrypted wallet snapshot and
the password already exposed in the matching legacy wallet-RPC process open offline and reproduce
the exact public address currently returned by that process? The fixed helper is
`scripts/deploy/wallet-keyfile-recovery-proof.py`.
The answer is emitted as exactly one status line:
```text
result=passed
```
or:
```text
result=refused
```
There is intentionally no diagnostic mode. Never add shell tracing, a debugger, `strace`, command
line capture, wallet-RPC response logging, or exception rendering to investigate a refusal. Those
would defeat the secret-handling boundary this proof is meant to preserve.
## What a pass means
A pass establishes all of the following for the selected profile at that moment:
- the expected legacy systemd unit has a stable MainPID, executable, UID/GID, cgroup, listener,
network flag, RPC port, and exact `--wallet-file` argument;
- its already-exposed inline wallet password could be copied from the NUL-delimited process command
line without rendering it;
- both fixed, root-only pre-production snapshot files were real, single-link regular files with the
audited metadata and were copied byte-for-byte into a newly created private `/run` directory;
- the matching wallet-RPC executable opened that copied pair offline on the fixed loopback proof
port as the exact legacy UID/GID, with an empty supplementary-group list, using a password file and
an ephemeral Digest credential;
- the proof endpoint rejected an unauthenticated request with HTTP 401, accepted an authenticated
`get_address` request with HTTP 200, returned an address shaped for the expected network, and that
address exactly equalled the current live address;
- that restored wallet signed one fixed, non-secret proof message and its own wallet-RPC verified the
signature as a spend-key signature (or, for the legacy response shape, verified the default
spend-key signature), so a watch-only keyfile cannot pass; and
- the candidate stopped, its listener disappeared, and only the exact ephemeral files and directory
were securely cleaned up.
The helper does not stop, start, restart, reload, reconfigure, or write to a live unit or live wallet.
The proof candidate is offline; it cannot refresh, submit transactions, or contact a daemon.
## What a pass does not mean
This is **not seed custody** and it is **not an off-host disaster-recovery test**. In particular, a
pass does not establish:
- that a mnemonic or seed has ever been recorded, is readable, is complete, or is held by the right
custodians;
- that the encrypted keyfile, its password, or a seed exists independently off this host—the password
used here comes only from the already-running process, not from an independent custody source;
- that Restic media, repository credentials, retention, or an actual off-host restore path works;
- that a restore can rescan chain history, discover the expected balance, or spend funds;
- that daemon, database, application, DNS, proxy, Nebula, or fleet recovery works; or
- that payouts, wagering, mainnet money movement, or a production release is authorized.
Do not query, display, export, hash, or automate retrieval of a mnemonic to fill this gap. Seed
custody requires a separate human-controlled inventory and an off-host restore drill with an agreed
handling ceremony. Until that evidence exists, record seed custody and off-host recovery as
**unproven**, even if this helper passes.
## Fixed audited profiles
No path, unit, binary, port, user, or RPC method is accepted from the caller. The only accepted
profile names are `monero-stagenet` and `wownero-mainnet`.
| Profile | Fixed legacy identity | Fixed snapshots | Private proof target |
| --- | --- | --- | --- |
| `monero-stagenet` | `monero-wallet-rpc.service`, `jw:jw`, `/usr/bin/monero-wallet-rpc`, wallet `/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test`, `127.0.0.1:38083`, `--stagenet` | `/var/backups/wowngeon/wallets/monero-stagenet-preprod-20260721T1140Z.wallet` and `/var/backups/wowngeon/wallets/monero-stagenet-preprod-20260721T1140Z.keys` | outer `/run/wowngeon-wallet-keyfile-proof-monero-stagenet`, inner `candidate`, `127.0.0.1:39084` |
| `wownero-mainnet` | `wownero-wallet-rpc.service`, `jw:jw`, `/usr/bin/wownero-wallet-rpc`, wallet `/home/jw/Programs/wow-11.3.0/game`, audited legacy listener `0.0.0.0:34570` | `/var/backups/wowngeon/wallets/wownero-mainnet-preprod-20260721T1140Z.wallet` and `/var/backups/wowngeon/wallets/wownero-mainnet-preprod-20260721T1140Z.keys` | outer `/run/wowngeon-wallet-keyfile-proof-wownero-mainnet`, inner `candidate`, `127.0.0.1:39570` |
The live equality observations are unauthenticated because the legacy sources are. Monero is
loopback-only; Wownero's wildcard listener is an insecure legacy source contained by the host
firewall, not a state this proof endorses. `current_live_address` makes one read-only public-address
observation solely for equality; a match is **not** an RPC-hardening or network-security pass. The
helper neither changes nor resolves the wildcard/unauthenticated service. Its proof candidate always
binds loopback and requires an ephemeral Digest credential.
Each fixed proof path is a root-owned outer directory, `root:jw` mode `0710`, which prevents the
candidate identity from listing or writing its parent. Root builds the inner `candidate` subtree and
files before publishing only that subtree as `jw:jw` mode `0700` with `0600` files. Immediately before
exec, the child clears supplementary groups, sets all real/effective/saved GIDs and UIDs to `jw:jw`,
clears retained capabilities, then reapplies no-dump, no-new-privileges, and zero-core limits. The
parent verifies empty inheritable/permitted/effective/ambient capability sets. Wallet-RPC never runs
as root.
The audited snapshot directory is `/var/backups/wowngeon/wallets`, `root:root` mode `0700`; each
snapshot is `root:root` mode `0600` with one link. Its parent is `/var/backups/wowngeon`,
`root:postgres` mode `0710`. Any deviation refuses the proof.
## Review and synthetic verification
Before any future host ceremony, review the helper and run the disposable fixture locally:
```bash
python3 -m py_compile scripts/deploy/wallet-keyfile-recovery-proof.py \
scripts/deploy/wallet-keyfile-recovery-fixture-test.py
python3 scripts/deploy/wallet-keyfile-recovery-fixture-test.py
```
The fixture uses only generated temporary files and synthetic credentials. It covers exact argument
selection and wiping, indirect or duplicate password refusal, network mismatch, link and metadata
failures, source mutation during copy, byte equality, cleanup containment, Digest authentication
shape, candidate config shape, privilege dropping, fixed-message spend signing/verification,
watch-only refusal, and proof-port collision. It never opens a real wallet.
Review must also confirm that the executable still contains only read-only `systemctl is-active` and
`systemctl show` calls; only the `get_address`, `sign`, and `verify` wallet RPC methods; no
caller-controlled paths, ports, or messages; and only the two status strings as runtime output. The
address and signature are never output or retained as ceremony evidence.
## Future authorized ceremony
Run Monero stagenet first. Wownero mainnet remains an offline keyfile-open proof only and must not be
treated as approval to enable payouts or submit a transaction.
Only after a reviewed revision is explicitly approved for installation should an operator install
that exact file to a root-owned, non-writable path such as
`/usr/local/sbin/wowngeon-wallet-keyfile-recovery-proof` with owner `root:root` and mode `0700`.
Do not run it from a mutable checkout. Record the reviewed source revision, but do not record wallet
bytes, addresses, passwords, command lines, Digest material, wallet-derived hashes, or RPC bodies.
Before each profile:
1. Confirm no debugger, tracing wrapper, shell xtrace, terminal recorder, or broad process collector
will observe the root helper.
2. Confirm the expected proof port is not reserved for another operation and the exact fixed `/run`
proof directory does not exist.
3. Confirm the legacy unit is expected to remain online and unchanged for the short proof window.
4. Invoke the installed helper as root with exactly one fixed profile argument and capture only its
one-line status.
The future invocations are:
```bash
sudo /usr/local/sbin/wowngeon-wallet-keyfile-recovery-proof monero-stagenet
sudo /usr/local/sbin/wowngeon-wallet-keyfile-recovery-proof wownero-mainnet
```
These commands are examples for a later authorized ceremony, not evidence they have been run.
After each invocation, verify only that its fixed proof port is closed and its fixed proof directory
is absent. A pass is invalid if cleanup did not complete; the helper reports refusal in that case.
## Refusal or interrupted execution
`result=refused` is intentionally non-diagnostic. Do not weaken the helper or print an exception to
learn why. Review the fixed-path metadata, expected unit state, listener ownership, port availability,
and synthetic suite through non-secret checks, then arrange a new reviewed ceremony.
If a fixed proof directory remains after interruption, **do not blindly remove it**. Treat it as
potentially containing wallet material. First establish through metadata-only checks that no proof
wallet-RPC process or proof-port listener remains, keep the incident off shared logs, and have an
authorized operator review cleanup of that exact path. Never use a recursive command against `/run`
or a variable/glob target.
The next independent backup milestone is a human-approved seed-custody inventory followed by a
restore from genuinely off-host media into an isolated environment. That is the evidence needed to
answer “do we have recoverable wallet seeds?”; this helper deliberately cannot answer it.