Wownerogue

A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
This commit is contained in:
jwinterm
2026-07-27 08:23:01 -04:00
commit fad2a695bd
578 changed files with 102281 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Single-player dungeon mode. On by default; set false for an instance that only runs
# other modes (e.g. Tavern-only).
SOLO_ENABLED=true
# Real-time multiplayer match mode (not yet available).
MULTIPLAYER_ENABLED=false
+64
View File
@@ -0,0 +1,64 @@
# Node.js dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Logs
logs/
*.log
*.tlog
*.out
*.pid
*.gz
# Environment variables. Ignore every runtime variant, not just the bare name:
# the deployment docs instruct operators to create .env.mainnet, .env.stagenet and
# similar, and an exact-name rule would let those be committed.
.env
.env.*
!.env.example
!.env.*.example
# Build files
dist/
build/
# Temporary files
tmp/
temp/
*.tmp
__pycache__/
*.py[cod]
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
Thumbs.db
TEMP_TODO.md
# 3D models / generation source artifacts (produced by the sprite pipeline; never commit:
# see ~/src/docs/animated-sprite-pipeline.md)
*.glb
*.fbx
*.blend
*.blend1
*.obj
*.stl
# AI-generated and heavy premium assets: hosted and lazy-loaded, not in the code tree
html/assets/generated/
# Operator-private documentation. Describes the operator's own deployment, books,
# and account topology; never belongs in the public repository.
docs/private/
# Local agent/editor state. Never repository content.
.claude/
.agents/
.codex/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Such Software LLC, John Winter Murphy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+499
View File
@@ -0,0 +1,499 @@
# Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next
block: reach the exit before the block lands, or the house wins. The same codebase runs the solo
dungeon, the social Tavern, and operator-selected PvP/race rulesets, with separate free and paid
leaderboards.
## Such Software-operated products
Such Software (`apps@such.software`) operates only these two public products:
| Service | Operated scope |
|---|---|
| `play.wowne.ro` | Wownero mainnet free play and pay-for-credits leaderboard/prestige play. Credits are non-redeemable; there are no prizes, cash-out, or payouts, and the service is not offered or marketed as gambling. Legal classification depends on applicable law. |
| `monerogue.app` | Monero **stagenet only**, with direct solo entry only. Single-player 2×/3× outcomes are test gambling mechanics using test coins with **NO REAL VALUE**. Purchased-credit entry and crypto-match payouts are off. Never send mainnet XMR. |
The repository is self-hostable under the MIT License. A third-party operator is solely responsible
for its deployment, product, legal compliance, funds, players, claims, and support. Such Software
does not operate, supervise, endorse, or accept responsibility for a third-party deployment.
## Features
- **Provably fair solo runs** with two-party, pre-entry seed commitments and per-depth layout
fingerprints
- **Multi-level descent** whose depth scales with the chain's block time
- **Live spectator mode**: watch active games in real time
- **Persistent chat** with a rolling 7-day server-side history
- **Transaction history**: payment and payout records per session
- **Multiple entry economies**: free play, per-run chain payment, or purchased credit bundles
- **Anonymous social Tavern** (no account required) with chat, live solo spectating, and in-place
race queues
- **Shared render kit** with Tiled, ASCII, Iso, and 3D techniques across the dungeon and Tavern,
driven by an operator-owned cosmetic catalog
- **Real-time multiplayer races** (optional): free, prestige-credit, or crypto winner-take-pot
races on a per-block cadence
- **Wallet RPC integration** for payment address issuance, payment detection, and batched payouts
## Requirements
- Node.js >= 22, npm >= 10
- PostgreSQL
- A Wownero or Monero wallet RPC endpoint (only required for paid modes)
## Quick Start
```bash
git clone https://github.com/Such-Software/wownerogue.git
cd wownerogue/src
npm install
cp .env.example .env # Edit with your database/wallet settings
npm run db:create # Initialize PostgreSQL
npm run dev # Start development server
```
Open http://localhost:3000 to play. `src/.env.example` is the full, annotated environment
template; the file at the repository root is a two-flag stub, not a starting point.
---
## Game Modes
`GAME_MODE` selects the solo entry economy for an instance.
| Mode | Cost | Payout | Leaderboard |
|------|------|--------|-------------|
| `FREE` | None | None | Pleb |
| `PAID_SINGLE` | Per-run chain payment | Profile-controlled | Paid prestige |
| `PAID_CREDITS` | Purchased credit | Profile-controlled | Paid prestige |
The Such Software production profiles are `src/.env.mainnet.example` (credits only, every payout
path off) and `src/.env.stagenet.example` (direct-entry-only stagenet 2×/3× solo test payouts,
purchased credits and crypto-match payouts off). Each opts into a startup contract with
`OPERATED_PRODUCT_PROFILE`; preflight refuses network, operator-identity, or economic-scope drift.
Generic self-hosts leave that variable unset and are not constrained by those product decisions.
## Match Mode (Multiplayer Races)
Real-time multiplayer races run when `MATCH_ENABLED=true`. A new race starts every crypto block as
long as at least two players are queued. Supported economies:
| Economy | Entry | Payout | Leaderboard |
|---|---|---|---|
| Free | None | None | Pleb |
| Prestige Credits | Credits | None | Prestige |
| Crypto Race | Race-entry ticket | Winner-take-pot minus house fee | Hall of Champions |
`MATCH_ENABLED` gates the creation of new rooms only; settlement reconciliation for existing rooms
runs regardless, so disabling it cannot strand an in-flight match. The Tavern's in-place race queue
additionally requires `TAVERN_ENABLED=true`.
See [`docs/MATCH_MODE.md`](docs/MATCH_MODE.md) for architecture, configuration, and API details, and
[`docs/RULESETS.md`](docs/RULESETS.md) for the Escape Race, Last Alive, Score Attack, and Co-op
Escape rulesets.
For deterministic multi-bot playtesting and 16:9/9:16 ad footage, use the loopback-only, free-only
capture harness documented in [`docs/PVP_CAPTURE.md`](docs/PVP_CAPTURE.md). It drives the real match
engine over Socket.IO but cannot connect to a deployment, database, wallet, or payout path.
## Difficulty and Pacing
Difficulty has two levers, both keyed to the crypto network, so `cryptoType` shapes the game.
**Presets** (`DIFFICULTY_PRESET`) set the per-level shape:
| Preset | Dungeon Size | Monster | Target House Win |
|--------|--------------|---------|------------------|
| `easy` | 30×15 | low | ~30% |
| `normal` | 45×22 | medium | ~55% |
| `hard` | 55×28 | high | ~65% |
| `casino` | 70×35 | very high | ~70% |
An instance with `GAME_MODE=FREE` and payments off defaults to `normal`; paid modes default to
`casino`. Every knob is env-overridable (`DUNGEON_WIDTH`, `DUNGEON_HEIGHT`, `MONSTER_SPEED`, and so
on).
**Multi-level depth (`levels`)** is the pacing lever and scales with block time. A run descends N
levels, each a preset-sized dungeon with a fair monster. A non-final exit takes the stairs down;
only the final exit escapes, and the treasure sits in the vault on the final level. Because total
run length scales with the chain's block time, the block timer supplies the house edge without a
giant map or a monster that moves faster than the player.
| 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 |
Level counts live in `NETWORK_TUNING` (`src/game/difficultyConfig.js`). Precedence, low to high:
preset, per-network tuning, env vars, explicit call-site overrides. Override the count with
`DUNGEON_LEVELS`; disable per-network tuning entirely with `NETWORK_TUNING_DISABLED=true`.
Wownero's mean block time is ~5 minutes measured against the live daemon, which places it closer to
Bitcoin than to Monero for calibration purposes. Mean block times per chain are declared once in
`src/chain/chainProfile.js`. See [`docs/BALANCE_SIM.md`](docs/BALANCE_SIM.md) for how balance is
measured and calibrated, and [`docs/MULTI_LEVEL.md`](docs/MULTI_LEVEL.md) for the descent mechanic.
---
## Chain Profiles
`src/chain/chainProfile.js` is the single source for everything chain-specific that is not an RPC
call: decimals and atomic divisor, mean block time, adapter family, and payment URI scheme.
| 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 unrecognized `CRYPTO_TYPE` resolves to a fallback profile shaped like XMR (12 decimals, 120000 ms
mean block time, monero family) so callers never crash on a mis-set value. Adding a chain means one
entry here plus a family adapter.
Note that WOW uses 11 decimals, not Monero's 12: 1 WOW = 10^11 atomic units.
---
## Provably Fair Gaming
1. The server publishes a one-use, socket-bound SHA-256 commitment to a secret server seed before
the client chooses its contribution. Issuance is separate from consumption, so the server cannot
grind its seed after seeing the player's.
2. The browser adds an independent WebCrypto client seed. Neither side alone selects the final
seed.
3. The effective seed is `HMAC-SHA256(serverSeed, clientSeed)` and deterministically generates each
dungeon depth along with its audit fingerprint.
4. After completion, the server seed and the per-depth layout manifest are revealed at
`/verify/:gameId` and `/api/verify/:gameId`. Server seeds for active games stay private.
The proof covers layout generation. Outcome execution is a separate server-authoritative concern:
without a signed input/event transcript, the proof does not independently replay block timing,
player moves, monster turns, the declared result, or payout delivery.
## Spectator Mode
The **WATCH GAMES** control lists active games. Spectators receive real-time updates via Socket.IO
rooms. Press ESC to exit.
## Transaction History
The **History** control shows the session's payment and payout records: total received from payouts,
payment history with status and credits received, and payout history with multipliers and
transaction hashes.
## Session Persistence
Sessions, including anonymous Tavern entry, use an anonymous token stored in `localStorage`:
- Persists across refreshes, browser closes, and server restarts.
- Lost when clearing cookies or `localStorage`, using incognito mode, or switching browsers or
devices.
A warning appears if `localStorage` is unavailable.
## Rendering and Cosmetics
The shared render kit provides four techniques: Tiled, ASCII, Iso, and 3D. Three.js is served from
the application's own origin under a versioned, immutable `/vendor/three/<version>` path, which
keeps it inside the production CSP. Setting `RENDERER_CDN_ENABLED=true` opts into third-party CDN
delivery instead and logs a warning in production.
Cosmetic packs are gated per pack, not by a blanket "has paid" flag. Each catalog entry carries a
tier and an optional lifetime-credits-purchased threshold, and a pack unlocks when either the user's
subscription tier reaches the pack's tier or their cumulative purchased credits reach its threshold.
Purchased credits are not deducted by unlocking. The default ladder is `original` (free), then
thresholds at 1, 5, 10, 20, 40, and 50 lifetime credits. In production the catalog is owned by the
`cosmetic_catalog` database table; the object in `src/multiplayer/entitlements.js` is the fallback
used when that table is unavailable.
See [`docs/RENDER_PACKS.md`](docs/RENDER_PACKS.md) and
[`docs/MONETIZATION.md`](docs/MONETIZATION.md).
---
## API Reference
### Public Endpoints
| Endpoint | Description |
|----------|-------------|
| `GET /health` | Redacted dependency/readiness summary |
| `GET /health/live` | Process liveness probe |
| `GET /health/ready` | Dependency readiness probe (503 while degraded) |
| `GET /api/game-modes` | Current modes and pricing |
| `GET /api/stats` | Public activity counters (short-lived cache, live online count) |
| `GET /api/disclosures` | Active commerce disclosure text for the running profile |
| `GET /api/leaderboard?board=pleb\|champions\|prestige&period=all\|week\|month` | One explicitly separated score board. An omitted `board` means `pleb`; legacy `all`, blank, and unknown board values return 400. An unknown `period` falls back to `all`. |
| `GET /api/user/:socketId/credits` | Credit balance |
| `GET /api/user/:socketId/mode` | Resolved entry mode for the user |
| `GET /api/user/:socketId/payment-options` | Available entry/payment options |
| `GET /api/user/:socketId/payments` | Payment history (paginated) |
| `GET /api/user/:socketId/payouts` | Payout history (paginated) |
| `POST /api/user/:socketId/address` | Set payout address |
| `POST /api/payment/create` | Create a payment request |
| `GET /api/payment/status/:paymentId` | Payment status |
| `GET /verify/:gameId` | Human-readable fairness verification page |
| `GET /api/verify/:gameId` | Machine-readable fairness proof |
| `GET /api/verify?serverSeed=&clientSeed=&effectiveSeed=&commitment=` | Stateless proof check for values you already hold |
`/`, `/tavern`, `/admin`, `/terms`, `/privacy`, and `/responsible-play` serve pages rather than JSON.
### Admin Endpoints
Require an `X-Admin-Key` header matching the `ADMIN_API_KEY` environment variable.
| Endpoint | Description |
|----------|-------------|
| `POST /api/admin/refund/payment` | Request an idempotent refund; consumed grants require review |
| `POST /api/admin/credits/adjust` | Add or remove credits from a user |
| `POST /api/admin/payouts/:id/retry` | Retry a failed payout |
| `GET /api/admin/users` | List users |
| `GET /api/admin/users/search` | Search users by socket ID or address |
| `GET /api/admin/users/:id` | User detail |
| `POST /api/admin/users/:id/chat-ban` | Apply or lift a chat ban |
| `GET /api/admin/chat` | Recent chat messages |
| `DELETE /api/admin/chat/:id` | Remove a chat message |
| `GET /api/admin/queue` | Inspect the entry queue |
| `POST /api/admin/queue/remove` | Remove an entry from the queue |
| `GET /api/admin/stats/overview` | Aggregate service stats |
| `GET /api/admin/stats/games` | Game stats |
| `GET /api/admin/stats/payouts` | Payout stats |
| `GET /api/admin/stats/refunds` | Refund stats |
| `POST /api/admin/alerts/test-email` | Send a test alert email |
**Example: refund a payment**
```bash
curl -X POST http://localhost:3000/api/admin/refund/payment \
-H "Content-Type: application/json" \
-H "X-Admin-Key: your-admin-key" \
-d '{"paymentId": 123, "reason": "Customer request", "sendFunds": true}'
```
**Example: adjust credits**
```bash
curl -X POST http://localhost:3000/api/admin/credits/adjust \
-H "Content-Type: application/json" \
-H "X-Admin-Key: your-admin-key" \
-d '{"socketId": "abc123", "amount": 5, "reason": "Compensation"}'
```
`amount` must be a non-zero safe integer in [-1000000, 1000000]; negative adjustments are applied
atomically and cannot drive a balance below zero.
### Socket.IO Events
**Client to server**
| Event | Description |
|-------|-------------|
| `register_client` | Bind the socket to a session token and receive identity/state |
| `fairness_offer_request` | Request a fresh server-seed commitment |
| `auto_start` | Start a run, using credits when available |
| `play_free` | Start a free run |
| `join_queue` | Join the next-block entry queue |
| `early_entry` | Enter the current block instead of waiting for the next |
| `player_move` | Move the player (direction 0-3) |
| `request_payment` | Request a payment address |
| `check_payment_status` | Poll a pending payment |
| `get_user_credits` | Request the current credit balance |
| `get_active_games` | List games available for spectating |
| `spectate_game` / `leave_spectate` | Enter and leave spectator mode |
| `chat_signed` / `chat_game_queue` | Send a signed chat message; queue chat-driven entry |
| `tavern_join` / `tavern_leave` / `tavern_move` / `tavern_chat` / `tavern_match_list` | Tavern presence, movement, chat, and race listing |
| `match_queue` / `match_move` / `match_leave` / `match_reconnect` | Multiplayer race queue and play |
**Server to client**
| Event | Description |
|-------|-------------|
| `socket_registered` / `session_token` / `session_resumed` | Session lifecycle |
| `fairness_offer` / `fairness_error` | Seed commitment issuance |
| `game_start` | Game initialized with dungeon state |
| `game_update` | State after movement, including `depth` and `maxDepth` for multi-level descent |
| `game_event` | Discrete in-run event: `descend`, `treasure_found`, `escaped`, `monster_caught` |
| `game_over` | Run ended |
| `game_settlement_pending` | Result recorded, settlement still in flight |
| `payment_created` / `payment_detected` / `payment_confirmed` / `payment_underpaid` / `payment_error` | Payment lifecycle |
| `credits_update` | Credit balance changed |
| `spectate_start` / `spectator_update` / `spectate_ended` | Spectator lifecycle |
| `chat_history` / `message` | Chat backlog and live messages |
| `leaderboard_update` / `win_feed` | Leaderboard and win ticker |
| `tavern_joined` / `tavern_update` / `tavern_match_tick` / `tavern_match_end` | Tavern state |
| `match_joined` / `match_end` / `match_settlement_pending` / `match_error` | Race lifecycle |
The client handles `descend` and `treasure_found` from `game_event` with an on-screen banner;
`escaped` and `monster_caught` are conveyed through `game_over`.
---
## Chat
Chat messages are persisted to the `chat_messages` table. A cleanup pass runs once per day and
deletes messages older than 7 days. Clients receive the most recent 50 messages on join. When no
database is available, chat falls back to a bounded in-memory buffer.
The Tavern prefers a shared global chat provider when one is injected. In that configuration a
Tavern message reaches every connected client, not just the Tavern room, and it is persisted along
with the rest of global chat. Only when no global provider is present does the Tavern fall back to
room-scoped, non-persisted chat.
See [`docs/CHAT_AND_NOSTR.md`](docs/CHAT_AND_NOSTR.md) for message signing and the optional nostr
relay path.
---
## Configuration
Key environment variables for a generic self-host. `src/.env.example` is the complete annotated
list.
```bash
# Core
NODE_ENV=production
PORT=3000
CRYPTO_TYPE=WOW
DIFFICULTY_PRESET=casino
# Payments
PAYMENTS_ENABLED=true
PAYMENT_MODES=direct,credits
DIRECT_GAME_PRICE=100000000000 # 1 WOW in atomic units
CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"900000000000","bonus":0}]'
# Payouts
PAYOUTS_ENABLED=false
DIRECT_PAYOUTS_ENABLED=false
DIRECT_PAYOUT_ESCAPE=2.0
DIRECT_PAYOUT_TREASURE=3.0
# Wallet RPC (required for paid modes)
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
WALLET_RPC_USER=user
WALLET_RPC_PASSWORD=use-a-distinct-strong-rpc-secret
# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=wownerogue
DB_USER=your_user
DB_PASSWORD=your_password
# Admin API (generate with: openssl rand -hex 32)
ADMIN_API_KEY=your-secure-key
# Optional modes
TAVERN_ENABLED=true
MATCH_ENABLED=false
# Network selection (Monero only; Wownero has mainnet only)
MONERO_NETWORK=mainnet
```
Amounts are always atomic units for the configured chain. See the chain profile table above for
per-chain decimals.
`npm run preflight` validates the environment against the configured product profile without
starting the service.
---
## Production Deployment
See [`docs/DEPLOY.md`](docs/DEPLOY.md) for production deployment, including:
- systemd service configuration
- Nginx or other reverse proxy setup with WebSocket support
- Database permissions and security hardening
[`docs/DEPLOY_INSTANCES.md`](docs/DEPLOY_INSTANCES.md) covers running multiple instances, such as
Wownero and Monero, on one server.
---
## Operations
See [`docs/LOGS_AND_BACKUP.md`](docs/LOGS_AND_BACKUP.md) for log management with systemd/journald,
automated PostgreSQL backups, and backup verification.
`scripts/deploy/wowngeon-db-backup.sh` writes a dump and then prunes dumps older than
`BACKUP_RETENTION_DAYS` (default 14), plus interrupted temporary files older than one day. It
rejects a non-positive or non-numeric retention value rather than defaulting silently.
Financial operations are documented in
[`docs/FINANCIAL_RECONCILIATION.md`](docs/FINANCIAL_RECONCILIATION.md),
[`docs/FINANCIAL_CONSTRAINT_VALIDATION.md`](docs/FINANCIAL_CONSTRAINT_VALIDATION.md),
[`docs/FINANCIAL_EVENT_EXPORT.md`](docs/FINANCIAL_EVENT_EXPORT.md), and
[`docs/STAGENET_FINANCIAL_CANARY.md`](docs/STAGENET_FINANCIAL_CANARY.md).
---
## Testing
```bash
cd src
npm test
```
Tests cover payment handlers, wallet RPC, security rules, movement logic, fairness verification, and
integration flows.
---
## Project Structure
```
src/
├── index.js # Entry point, HTTP routes, static and vendor asset serving
├── auth/ # Session and wallet-based authentication
├── chain/ # Chain profile registry
├── config/ # Payment config, environment validation, product profiles
├── db/ # PostgreSQL layer
├── game/ # Dungeon generation, player, monster, fairness, mode resolution
├── middleware/ # Express middleware
├── migrations/ # Numbered SQL migrations
├── money/ # Atomic-unit arithmetic
├── multiplayer/ # Entitlements, appearance, cosmetic catalog
├── network/ # Socket handlers, chat, queue, spectator, match, Tavern
├── payments/ # Wallet RPC, QR codes
├── routes/ # Admin, auth, and leaderboard routers
├── rpc/ # Daemon and wallet clients
├── services/ # Health, alerts, refunds, payouts, subscriptions, exports
├── sim/ # Balance simulation harness
├── utils/ # Errors, memory management, nostr helpers
└── views/ # Server-rendered legal and verification pages
html/
├── index.html # Game client
├── tavern.html # Tavern client
├── match.html # Match client
├── admin.html # Admin dashboard
├── assets/ # Render packs and images
└── js/ # Frontend modules (core, render, network, ui, input)
test/
└── *.test.js # Jest tests
docs/ # Architecture, deployment, payments, and design references
scripts/ # Deployment units, backup, asset build, wallet maintenance
```
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) describes how these pieces fit together.
---
## License
The source is available under the [MIT License](LICENSE). MIT permission to use, copy, modify,
merge, publish, distribute, sublicense, and sell copies is subject to including 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 the License.
The MIT License governs rights in the source. Documentation, examples, and product disclosures are
informational only; they are not legal advice or a determination that a deployment complies with
applicable law. Such Software operates only the two products and scopes listed above. The MIT
License does not make an independent deployment a Such Software service or authorize it to claim
Such Software sponsorship or endorsement; its operator remains solely responsible for the service.
+128
View File
@@ -0,0 +1,128 @@
# Architecture
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.
This page is the index for the architecture docs. Each subsystem has its own page.
## System map
```
┌──────────── players (browser) ─────────────┐
│ index.html · tavern.html · match.html │
│ html/js/render (render kit) │
└───────────────┬───────────────┬────────────┘
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
└─────────────────────────────────────────────┘
│ │
PostgreSQL Wownero/Monero wallet-RPC, BTCPay/checkout gateways
```
`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/`.
## Subsystem guide
| 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) |
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 and GRIN: `decimalsFor`, `meanBlockTimeMsFor`, `familyFor` (`monero` / `utxo` /
`mimblewimble`), `uriSchemeFor` and `atomicDivisor` (BigInt).
| 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/` (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).
## Provable fairness
`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.
## Conventions
- **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`).
Source files carry JSDoc for API-level detail; these pages cover the seams between subsystems and
the operator-facing intent behind them.
+122
View File
@@ -0,0 +1,122 @@
# Balance simulation and per-network calibration
Wownerogue is a block-timed roguelike: a single-player run is bounded by the blockchain, and the
house edge comes from whether the player clears the dungeon before the next block lands. The
deadline is a random variable and every supported chain has a different block time, so difficulty is
measured rather than estimated. This document covers the simulator that measures it and the
per-network tuning it feeds.
## Timing model
A run starts on block *N* and ends as a loss if the player has not escaped by the time block *N+1*
arrives (`src/network/socketHandlers.js`, `checkGamesTimeout`). Movement is real time within that
window, with a 100 ms per-move cooldown (`src/game/movementManager.js`). A grace period,
`GAME_START_GRACE_MS` (default 2000 ms, `0` disables), suppresses only the degenerate case where a
block lands in the same instant the game starts; random block timing is otherwise preserved intact.
Block arrival is a Poisson process, so the run window is exponentially distributed with mean equal
to the network's block time. Entering just after a block yields a near-full interval; entering just
before yields seconds. That variance is the core mechanic.
For a run that escapes at wall-clock time `T`:
```
P(survive) = P(block lands after T) = exp(-T / meanBlockTime)
house_win(run) = caught or stuck ? 1 : (1 - exp(-T / meanBlockTime))
```
The random deadline is integrated out analytically, so no Monte Carlo over deadlines is required.
Mean block times come from `src/chain/chainProfile.js` (`meanBlockTimeMs`):
| Chain | Mean block time |
|-------|-----------------|
| GRIN | 1 min |
| XMR | 2 min |
| LTC | 2.5 min |
| WOW | 5 min |
| BTC | 10 min |
Unknown or unset chain ids resolve to a fallback profile of 12 decimals and a 120000 ms block time,
so a mis-set `CRYPTO_TYPE` degrades to XMR-shaped timing instead of crashing.
## The simulator (`src/sim/`)
The harness drives the real engine (`src/game/game.js`: same dungeon generator, monster AI and
movement as production) with headless bots until each run reaches a terminal outcome, then reports
the measured distribution. It never reimplements game rules; it is a thin layer over `Game`.
| File | Role |
|------|------|
| `simulate.js` | Harness and CLI. Per preset: escape, treasure, caught and stuck rates; completion-move and completion-time percentiles; measured house-win per network via the identity above. |
| `simBots.js` | Bot policies. `omniscient-*` know the full map and take BFS-optimal paths; `explorer-*` play under fog of war and reset their knowledge on descent. Neither policy evades the monster. |
| `pathfind.js` | Dependency-free 4-connected BFS distance field and downhill step, so navigation stays auditable and separate from game rules. |
| `calibrate.js`| Per-network solver: scales dungeon size by `√(blockTime / 120000)` for pacing, then binary-searches monster speed to a target house-win. |
Run:
```bash
node src/sim/simulate.js --runs 200 --bot explorer-greedy --presets normal,casino
node src/sim/calibrate.js --target 0.70 --preset casino
```
`simulate.js` accepts `--runs`, `--bot`, `--presets`, `--nets`, `--cadence`, `--movecap` and
`--json`. Bots are `omniscient-escape`, `omniscient-greedy`, `explorer-escape` and
`explorer-greedy` (default).
`--cadence` (ms per move, default 320) is the moves-to-seconds assumption the block-time math rests
on; sweep it when calibrating against real player pacing.
Two properties of the harness matter when reading its output:
- Because neither bot evades the monster, both over-count catches, so reported house-win is an upper
band rather than a point estimate. A skilled human wins more often.
- One simulated dungeon configuration is scored against every network in `--nets`. The dungeon
itself is built from `DIFFICULTY_PRESET` and the process `CRYPTO_TYPE` (default `WOW`), so the
per-network columns differ only in block time, not in level count. Set `CRYPTO_TYPE` explicitly to
simulate a specific chain's level count.
`test/sim_ultra.test.js` guards the harness structurally: it asserts that bots reach terminal
outcomes and that rates are well formed and partition the runs. It asserts nothing about balance
values, which are expected to move.
## Findings
1. Presets declare a `targetHouseWinRate` but measured rates land below it, and the edge varies by
roughly 15 points across chains for an identical dungeon purely from block time.
2. Dungeon size is not a clean edge lever. A larger map lengthens the run (timer edge up) but gives
the player more room to dodge (monster edge down), so house-win self-cancels near 60% until the
map becomes an unplayable slog. Size is useful as a pacing knob, not an edge knob.
3. A fast monster is both unpleasant and ineffective on slow chains. On WOW's 5-minute blocks a
single dungeon clears in about a minute, so even a 2.2x monster caps house-win near 64% because
the timer barely bites.
The lever that follows from this is run length: descend N preset-sized levels, each with a fair
monster, so cumulative run time supplies the edge and levels scale with block time. `monsterSpeed`
is deliberately not used as a per-network lever. See `docs/MULTI_LEVEL.md` for the mechanic.
## Per-network tuning (`NETWORK_TUNING`)
`src/game/difficultyConfig.js` folds per-network tuning onto the resolved preset. Precedence, low to
high: preset, network tuning, environment variables, explicit overrides. Operator settings such as
`DUNGEON_*`, `MONSTER_*` and `DUNGEON_LEVELS` therefore win over the tuning, and
`NETWORK_TUNING_DISABLED=true` removes it entirely.
The tuning sets only `levels`. Dungeon size and monster parameters stay at the preset values.
| Chain | Levels |
|-------|--------|
| GRIN | 1 |
| XMR | 1 |
| LTC | 2 |
| WOW | 2 |
| BTC | 3 |
Level counts are conservative relative to the raw pacing formula (block time divided by roughly 75
seconds per level), because a deep descent inside one block deadline is rarely completable and makes
reach-the-exit-to-win unintuitive.
`Game` reads the resolved count as `maxDepth`. Reaching a non-final exit descends to a fresh level
and emits a `descend` event; only the final level's exit ends the run as a win, and treasure is
placed only on the final level. The client handles `game_event` (including `descend`) in
`html/js/network/socketHandlers.js` and surfaces depth progress to the player.
+191
View File
@@ -0,0 +1,191 @@
# Chat and Nostr
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 are identical either way.
## The provider seam
`src/network/chat/ChatProvider.js` is the abstraction every chat caller uses:
```
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` 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})` 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
`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.
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.
## Identity models
`NostrChatProvider` takes an injectable signer with the contract `{ pubkey, sign(template) }`. That
one object is the whole difference between the two identity models.
### 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
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` 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 server registers the `chat_signed` socket event in `src/network/socketHandlers.js`.
## Relays and tiers
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.
| 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 |
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 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 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, 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
```
+109
View File
@@ -0,0 +1,109 @@
# Disposable clone migration gate
`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 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` 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.
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
database=monerogue_restore_20260721t220048z_28d513b29b36
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 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
DB_PORT=5432
DB_NAME=monerogue_restore_20260721t220048z_28d513b29b36
DB_USER=monerogue
PGPASSFILE=/run/wowngeon/clone-migration.pgpass
CLONE_MIGRATION_EXPECT_DATABASE=monerogue_restore_20260721t220048z_28d513b29b36
CLONE_MIGRATION_CONFIRM=MIGRATE_DISPOSABLE_CLONE:monerogue_restore_20260721t220048z_28d513b29b36
```
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 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 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
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 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.
+475
View File
@@ -0,0 +1,475 @@
# Production Deployment Guide
This guide covers deploying Wownerogue to a production environment.
## Operated profiles versus independent deployments
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.
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 (`src/package.json` requires `node >=22`, `npm >=10`)
- PostgreSQL 12 or later
- Wownero or Monero wallet-rpc running and synced
- 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.
---
## Deployment Checklist
- [ ] Set `NODE_ENV=production`
- [ ] Configure TLS via reverse proxy (Nginx/Caddy)
- [ ] Set secure database credentials
- [ ] Configure wallet-rpc with authentication
- [ ] 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 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 and model requests
stay same-origin
---
## Create Dedicated User
Create an isolated, non-login service identity. Release directories and the `current` link remain
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
sudo install -d -m 0750 -o root -g wownerogue /var/www/wownerogue
sudo install -d -m 0750 -o root -g wownerogue /var/www/wownerogue/releases
sudo install -d -m 0750 -o root -g wownerogue /etc/wownerogue
# Create log directory
sudo install -d -m 0750 -o wownerogue -g wownerogue /var/log/wownerogue
```
---
## Stage reviewed artifacts, not a repository clone
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 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
dependencies there, change `current`, restart an application, or run migrations merely because a
blob has arrived.
---
## Install Node.js
Install Node.js LTS using NodeSource (run as root):
```bash
# Install Node.js 22.x LTS (or check https://nodejs.org for current LTS)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
# Verify installation
node --version
npm --version
```
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,
explicitly authorized source-review operation; never run `npm audit fix` in staging or on a live
release.
---
## Set File Permissions
```bash
# Releases and selector are immutable to the service identity.
sudo chown root:wownerogue /var/www/wownerogue /var/www/wownerogue/releases
sudo chmod 0750 /var/www/wownerogue /var/www/wownerogue/releases
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 fleet activation role verifies every
individual release's ownership and modes before activation.
**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
CREATE USER wownerogue WITH PASSWORD 'secure-password-here';
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.
---
## Database Setup
### Run Migrations
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.
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 stays
unset: those no-value test events are marked ignored locally.
### 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)
```bash
npm run db:drop # Drop all tables
npm run db:create # Recreate schema
```
---
## systemd Service
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 and promotion, candidate validation,
and one-instance activation procedures in [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md) and the
hash-pinned `such-fleet/RUNBOOK-wowngeon.md`. Independent MIT operators design and review their own
equivalent control plane.
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
---
## Reverse Proxy (Nginx Proxy Manager)
If using Nginx Proxy Manager:
1. **Add Proxy Host**
- Domain Names: `yourdomain.com`
- Scheme: `http`
- Forward Hostname/IP: your server's LAN IP (for example `192.168.1.100`)
- Forward Port: `3000`
- 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:
```nginx
location /socket.io/ {
proxy_pass http://192.168.1.100:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
```
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 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.
---
## Multi-Instance Deployment
To run multiple instances, for example Wownero on port 3000 and Monero stagenet on port 3001:
### Directory Structure
```
/var/www/
├── wownerogue/ # releases/<id> + current symlink (port 3000)
└── monerogue/ # releases/<id> + current symlink (port 3001)
/etc/
├── wownerogue/app.env # root:wownerogue, 0640
└── monerogue/app.env # root:monerogue, 0640
```
### Configuration Differences
| Setting | Wownero Instance | Monero Instance |
|---------|------------------|-----------------|
| `PORT` | 3000 | 3001 |
| `CRYPTO_TYPE` | WOW | XMR |
| `MONERO_NETWORK` | mainnet | stagenet |
| `DB_NAME` | wownerogue | monerogue |
| `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 |
### 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://192.0.2.40:34568,http://192.0.2.41:34568
```
`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 cannot
silently serve a different chain's data;
- 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, 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, 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 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.
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
# 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 unlocked, plus fees)
node scripts/splitOutputs.js --amount 10 --count 30
```
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.** 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.
For typical traffic, 20 to 30 outputs of 10 WOW each provides good concurrency headroom.
---
## Monitoring
### Check Service Status
```bash
systemctl status wownerogue
journalctl -u wownerogue -f # Follow logs
```
### Health Endpoints
```bash
curl http://localhost:3000/health/live
curl --fail http://localhost:3000/health/ready
```
`/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
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 and failed payouts
- Game statistics
- User search
---
## Troubleshooting
### Server Won't Start
```bash
# Check logs for errors
journalctl -u wownerogue -n 50
# Validate the protected environment without printing it
cd /var/www/wownerogue/current/src
sudo -u wownerogue bash -c 'set -a; . /etc/wownerogue/app.env; set +a; npm run preflight'
# Test database connection
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
# Test wallet RPC directly
curl --digest -u user:password -X POST http://127.0.0.1:34570/json_rpc \
-d '{"jsonrpc":"2.0","id":"0","method":"get_balance"}' \
-H 'Content-Type: application/json'
```
### Database Migrations
If migrations fail, check the `schema_migrations` ledger:
```sql
SELECT filename, applied_at FROM schema_migrations ORDER BY filename;
```
Migrations are tracked by filename to prevent duplicate execution.
+270
View File
@@ -0,0 +1,270 @@
# Two-instance deployment runbook
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 | 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 |
NPM forwards both domains to these ports and terminates TLS, so this runbook contains no
reverse-proxy configuration.
## Scope and legal boundary
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.
`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.
`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.
Independent MIT deployments leave `OPERATED_PRODUCT_PROFILE` unset and are not constrained by any of
the product decisions above.
## Prerequisites
- 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)).
## 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
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 means no payout address is collected
CREDITS_REQUIRES_ADDRESS=false
TRUST_PROXY=true
TRUST_PROXY_HOPS=1 # exactly one NPM hop
```
Amounts on this instance are WOW atomic units (1 WOW = 1e11).
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 # 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)'
```
## 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.
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.
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.
## 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
sudo install -d -m 0750 -o root -g monerogue /var/www/monerogue
sudo install -d -m 0750 -o root -g monerogue /var/www/monerogue/releases
sudo install -d -m 0750 -o root -g monerogue /etc/monerogue
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 and do not recursively `chown` `/var/www/monerogue` to the service user.
## Step 4: Configure the monerogue instance
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
PORT=3001
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
DB_NAME=monerogue
DB_USER=monerogue
DB_PASSWORD=CHANGE_ME
ADMIN_API_KEY=GENERATE_WITH_openssl_rand_hex_32
SMIRK_ENABLED=false # Smirk does not support stagenet
```
Economy values, all in stagenet test coins with no real value:
```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/<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: Activate the instance and close the network
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 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.
Confirm afterwards that direct access to ports 3000 and 3001 is refused while both domains, their
websocket upgrades, and local readiness probes still work.
## 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` 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.
+87
View File
@@ -0,0 +1,87 @@
# Historical financial constraint release gate
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. 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 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 \
--set=expected_database=EXACT_DATABASE_NAME \
--file=scripts/deploy/financial-constraint-audit.sql
```
Exit codes:
| 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
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.
The script therefore refuses to reach `BEGIN` unless all three conditions hold:
- 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:
```bash
psql -X -v ON_ERROR_STOP=1 \
--set=expected_database=monerogue_release_restore \
--set=confirm_disposable=I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE \
--file=scripts/deploy/financial-constraint-validate-restore.sql
```
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
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
```
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.
+165
View File
@@ -0,0 +1,165 @@
# Financial event export
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.
## Event sources
| 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) |
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`.
## 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
FINANCIAL_EVENT_SINK_TOKEN=<strong protected bearer token>
FINANCIAL_EVENT_ACCOUNT_REF=wowngeon:wow-receipts
FINANCIAL_EVENT_POLL_MS=60000
FINANCIAL_EVENT_BATCH_SIZE=20
FINANCIAL_EVENT_MAX_ATTEMPTS=8
```
The full environment template is `src/.env.example`; the mainnet and stagenet profiles are
`src/.env.mainnet.example` and `src/.env.stagenet.example`.
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
Before enabling mainnet export, prove in a disposable environment that the sink:
1. authenticates the protected bearer token over TLS;
2. deduplicates repeated requests by `Idempotency-Key`;
3. durably records the event before returning 2xx;
4. rejects malformed events without echoing secrets; and
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. Running without a sink is permitted, but confirmed mainnet events then
accumulate as pending rows and must be reconciled by another route.
+95
View File
@@ -0,0 +1,95 @@
# Financial reconciliation
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.
## Where to look
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 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 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.
## Alerts
`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.
+403
View File
@@ -0,0 +1,403 @@
# Smirk Wallet Integration
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.
---
## Enablement
Smirk is opt-in and mainnet-only:
- `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
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 ----------->| |
| 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 ---->| |
```
### POST `/api/auth/smirk/challenge`
Issue a single-use nonce.
**Headers:** `X-Session-Token: <session token>`
**Request:**
```json
{ "socketId": "<socket.io id>" }
```
**Response:**
```json
{ "challenge": "<64-char hex string>", "expiresIn": 300 }
```
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 proof and link the wallet to the account.
**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>",
"challenge": "<the challenge string>",
"publicKey": "<Ed25519 public key, hex>",
"signature": "<Ed25519 signature, hex>"
}
```
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" }
```
**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`
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.
---
## Account adoption
`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. 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 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 nacl = require('tweetnacl');
const challengeBytes = Buffer.from(challenge, 'utf8');
const signatureBytes = Buffer.from(signature, 'hex');
const publicKeyBytes = Buffer.from(publicKey, 'hex');
const valid = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes);
```
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
### Detection
```javascript
if (typeof window.smirk !== 'undefined') {
// Extension is installed
}
```
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; later calls from
an approved origin return keys immediately.
```javascript
const keys = await window.smirk.connect();
// keys.wow - Ed25519 public spend key (hex)
// keys.btc - secp256k1 compressed public key (hex)
// keys.ltc - secp256k1 compressed public key (hex)
// keys.xmr - Ed25519 public spend key (hex)
// keys.grin - Ed25519 public key (hex)
```
### `smirk.signMessage(message)` -> `Promise<SignResult>`
Signs a message with all wallet keys. Always prompts for user approval.
```javascript
const result = await window.smirk.signMessage(challenge);
const wowSig = result.signatures.find(s => s.asset === 'wow');
// wowSig.signature - 64-byte Ed25519 signature (hex)
// wowSig.publicKey - public key that signed (hex)
```
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()`.
```javascript
const addrs = await window.smirk.getAddresses();
// addrs.wow - "Wo..." (97-char CryptoNote standard address)
// addrs.btc - "bc1q..." (bech32 P2WPKH)
// addrs.ltc - "ltc1q..." (bech32 P2WPKH)
// addrs.xmr - "4..." (95-char CryptoNote standard address)
// addrs.grin - "grin1..." (bech32 slatepack address)
```
### `smirk.requestPayment(options)` -> `Promise<PaymentResult>`
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 the approval popup
});
// result.txid - Transaction hash (hex)
// result.amount - Amount sent (string)
```
`amount` is the human-readable value, for example `"1"` for 1 WOW. The extension converts to atomic
units internally.
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>`
Whether the current origin is approved.
### `smirk.disconnect()` -> `Promise<void>`
Revoke site access.
### `smirk.getPublicKeys()` -> `Promise<PublicKeys | null>`
Public keys without prompting. Returns `null` when the origin is not connected.
---
## Client implementation
**Sign-in:** `html/js/network/smirkAuth.js`.
- `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
`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 | 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 |
`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
| 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 | 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) |
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.
+259
View File
@@ -0,0 +1,259 @@
# Logs and Backup
Operational reference for application logs and PostgreSQL backups in a production deployment.
---
## Application Logs
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.
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 a unit
journalctl -u wownerogue
# Follow live
journalctl -u wownerogue -f
# Last 100 lines
journalctl -u wownerogue -n 100
# Since today
journalctl -u wownerogue --since today
# A specific time range
journalctl -u wownerogue --since "2026-01-01" --until "2026-01-15"
# 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 Backups
### The Validated Backup Job
`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:
- 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.
Configuration comes from two environment variables:
| 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`. |
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.
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.
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
# Backup destination, owned by the database user only
sudo install -d -o postgres -g postgres -m 0700 /var/backups/wowngeon/daily
# 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/
sudo systemctl daemon-reload
sudo systemctl enable --now wowngeon-db-backup.timer
# Confirm the schedule
systemctl list-timers wowngeon-db-backup.timer
```
### Running a Backup on Demand
```bash
sudo systemctl start wowngeon-db-backup.service
journalctl -u wowngeon-db-backup.service -n 50
ls -lh /var/backups/wowngeon/daily/
```
Dumps are named `<database>-<UTC timestamp>.dump`, for example
`wownerogue-20260726T032013Z.dump`.
### Verifying a Backup
Check integrity before trusting a dump:
```bash
cd /var/backups/wowngeon/daily
sudo -u postgres sha256sum -c wownerogue-20260726T032013Z.dump.sha256
sudo -u postgres pg_restore --list wownerogue-20260726T032013Z.dump | head
```
### Restoring
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
```
---
## What Database Backups Do Not Cover
A database dump alone does not restore a working instance. Back up these separately, with access
controls at least as strict as the dumps:
- **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.
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.
---
## Monitoring Checklist
- 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
+295
View File
@@ -0,0 +1,295 @@
# Match Mode: Real-Time Multiplayer Races
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 is opt-in. Nothing in this document is active unless `MATCH_ENABLED=true`.
## Supported economies
| Economy | Entry cost | Payout | Leaderboard |
|---|---|---|---|
| `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 |
`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.
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.
## Lifecycle
`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 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. `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
classic race uses exit/proximity ranking; last-alive and score-attack use their own competitive
rankings.
## Architecture
### Engine layer (`src/multiplayer/`)
- `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` holds serialization helpers that produce broadcast and persistence shapes,
including the renderer-agnostic `gameState` consumed by `html/js/render/sceneModel.js`.
### Transport and lifecycle layer (`src/network/`)
- `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
Schema lives in `src/migrations/`:
- `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 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, 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, not extra seed material.
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.
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
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)
MATCH_MAX_PLAYERS=4
# Server tick interval in ms
MATCH_TICK_MS=250
# Server-selected gameplay ruleset; clients cannot override it.
# race | last-alive | score-attack | coop-escape
MATCH_RULESET_ID=race
# Paid freeze target distance and activation confirmation depth.
# Production requires explicit safe integers from 2 through 100.
MATCH_PAID_ENTROPY_DELAY_BLOCKS=2
MATCH_PAID_ENTROPY_CONFIRMATIONS=2
# Minimum race duration before the next block can end it
MATCH_MIN_DURATION_MS=20000
# Hard ceiling after which a race is force-ended
MATCH_HARD_CEILING_MS=240000
# Credits cost for prestige-only races
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
MATCH_ENTRY_FEE_ATOMIC=5000000000
# Crypto admission requires both explicit match gates and an outer atomic-unit payout cap
MATCH_CRYPTO_RACE_ENABLED=false
MATCH_PAYOUTS_ENABLED=false
MATCH_PAYOUT_MAX=50000000000
```
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` 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` 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_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 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. 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 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` and `match_entrants`. It is excluded
from individual boards.
## Tests
```bash
cd src
npx jest test/match
```
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_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` 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, 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 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)
+166
View File
@@ -0,0 +1,166 @@
# Monetization: credits, catalog, and entitlements
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
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`, repriced by migration `028`) is the operator-owned product
table and the single server-authoritative source for pack definitions:
```
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
`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.
```
active subscription -> tierForNpub(npub) -> user.subscription_tier -> levelForUser -> tier
|- relay: chat perks
|- catalog: pack unlocks
```
`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.
| Environment variable | Meaning |
|---|---|
| `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 |
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.
## Reaching the client
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.
`?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.
## Operated product profiles
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 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
```
+88
View File
@@ -0,0 +1,88 @@
# Multi-level dungeons
A run descends through `maxDepth` levels instead of playing a single map. Depth is the pacing lever
that lets the block timer supply the house edge on slow chains without an oversized dungeon or a
monster that moves faster than the player. See [`BALANCE_SIM.md`](BALANCE_SIM.md) for the balance
model behind that choice.
## Mechanic
- Each level is a normal preset-sized dungeon with its own entrance, exit, and a freshly placed
monster.
- Reaching a **non-final** exit takes the **stairs down**: a new level is generated, the player is
placed at its entrance, a new monster spawns, and fog of war resets. Descending is not a win.
- Reaching the **final** level's exit **escapes** the dungeon and wins the run.
- **Treasure exists only on the final level.** Intermediate levels are a pure race to the stairs, so
the treasure payout multiplier requires descending all the way and getting back out.
- `maxDepth = 1` is the single-level case: `levelSeed` returns the master seed unchanged at depth 1,
so a one-level run's layout is exactly what the master seed alone produces.
- The monster does not take its turn on the arrival move of a descent. `afterPlayerMove` returns
early on a `descend` result, so the player is never caught by a monster they have not yet seen.
## Provable fairness across the descent
Every level in a run regenerates from the one committed seed. `levelSeed(masterSeed, depth)` in
`provablyFair.js` returns the master seed verbatim for level 1 and salts it as `<seed>:L<depth>` for
deeper levels. Any level can be reproduced with:
```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 per chain by `NETWORK_TUNING` in
`src/game/difficultyConfig.js`. Depth scales with block time so that a slower chain gives a longer
run before the block lands:
| Chain | Approx. block time | Levels |
|---|---|---|
| GRIN | ~1 min | 1 |
| XMR | ~2 min | 1 |
| LTC | ~2.5 min | 2 |
| WOW | ~5 min | 2 |
| BTC | ~10 min | 3 |
Resolution order for the level count, lowest precedence first: preset (no `levels` key, so the
effective floor is 1) → `NETWORK_TUNING` for the chain → `customOverrides.levels` → the
`DUNGEON_LEVELS` environment variable, which always wins and is clamped to a minimum of 1.
`NETWORK_TUNING_DISABLED=true` skips per-network tuning entirely, which leaves the run at a single
level unless `DUNGEON_LEVELS` or an explicit override supplies a count. An unknown `cryptoType` is
also left untuned and therefore single-level.
Dungeon size and monster behaviour are not varied by depth: `applyNetworkTuning` folds only the
level count onto the resolved difficulty preset, and every level uses the same preset dimensions and
the same fair monster settings.
## Where it lives
| Concern | Location |
|---|---|
| Level generation and descent | `src/game/game.js`: `_generateLevel(depth)`, `_descend()`, the exit branch in `movePlayer` returning `event: 'descend'`, and `depth`/`maxDepth` in `getState` |
| All-level fingerprint manifest | `src/game/game.js`: `_buildLayoutFingerprintManifest()`, `getProofContext()` |
| Per-level seed | `src/game/provablyFair.js`: `levelSeed()` |
| Level count | `src/game/difficultyConfig.js`: `NETWORK_TUNING`, `applyNetworkTuning()`, `DUNGEON_LEVELS` |
| Verification | `src/game/fairnessVerifier.js` |
| Server wiring | `src/network/socketHandlers.js`: the `'descend'` branch in `afterPlayerMove` (no game over, no monster move on arrival) |
| Event emission | `src/game/movementManager.js`: emits `game_event` with `event`, `depth`, `maxDepth` after the `game_update` |
| Client state | `html/js/core/gameState.js`: clears accumulated map, explored, and visible tiles on a `depth` change so the previous level does not ghost through the new one |
| Client feedback | `html/js/network/socketHandlers.js`: the `game_event` handler renders the descend message and on-screen banner with `Level N / M` |
| Sim | `src/sim/simBots.js`: the explorer bot clears its known-tile set and remembered objectives on a depth change |
| Tests | `test/multiLevel_ultra.test.js`, `test/networkTuning_ultra.test.js` |
+171
View File
@@ -0,0 +1,171 @@
# Payments
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.
## Provider contract
`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: 'pending'|'processing'|'paid'|'expired'|'invalid', complete, paidAtomic, confirmations, raw}
startWatch(ref, onUpdate, intervalMs) / stopWatch(ref)
sendPayout({chain, address, amountAtomic}) -> {txids, raw} (optional)
validateAddress(chain, address) -> {valid, reason} (optional)
supportsChain(chain) -> boolean
```
`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.
## Providers
| 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` |
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.
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.
## Bootstrap and routing
`buildProviderRegistry({env, walletService})` in `src/payments/providers/index.js` is the single
wiring seam:
- 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.
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.
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.
## Invoice lifecycle
**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.
**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`.
**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.
**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/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, 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
```
+168
View File
@@ -0,0 +1,168 @@
# Production disclosures and paid-action gate
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 two public services and nothing else.
| 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. |
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 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
deployment a Such Software service or authorize it to claim Such Software sponsorship or
endorsement. Such Software does not operate, supervise, endorse, or accept responsibility for
third-party deployments.
## Required production settings
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
LEGAL_POLICY_VERSION=2026-07-22-v1
TERMS_EFFECTIVE_DATE=2026-07-22
MINIMUM_AGE=18
PAID_ACKNOWLEDGEMENT_REQUIRED=true
```
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 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.
- `/terms`, `/privacy`, and `/responsible-play` render the active mode and operator metadata.
- 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. 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, 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 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.
## Operator decisions before enabling real-value modes
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 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, 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 exercises end-to-end mechanics without representing test coins as money. Mainnet payouts
are a separate, explicit operational and legal decision.
## 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`. 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.
+201
View File
@@ -0,0 +1,201 @@
# Deterministic PvP capture harness
`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 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 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, 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 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;
- serves only repository assets and forces the CDN renderer flag off, so the 3D renderer is
unavailable here.
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
From `src/`:
```bash
NODE_ENV=development npm run capture:pvp -- \
--confirm-local-free-only \
--ruleset race \
--players 4 \
--mode tiles \
--output /tmp/wowngeon-pvp-capture.webm \
--screenshot /tmp/wowngeon-pvp-final.png
```
`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 -- \
--confirm-local-free-only \
--no-video \
--ticks 120 \
--trace /tmp/wowngeon-pvp-trace.json
```
## Options
Run `npm run capture:pvp -- --help` for the same list at the terminal.
| 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 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
Use the same safety acknowledgement for every take:
| `--ruleset` | Bot objective | Result card |
|---|---|---|
| `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 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:
```bash
NODE_ENV=development npm run capture:pvp -- \
--confirm-local-free-only \
--ruleset last-alive \
--players 6 \
--camera action \
--viewport 1920x1080 \
--output /tmp/wowngeon-last-alive-ad.webm \
--trace /tmp/wowngeon-last-alive-ad.json
```
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 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 -- \
--confirm-local-free-only \
--ruleset race \
--players 6 \
--focus bot-4 \
--viewport 1080x1920 \
--output /tmp/wowngeon-pvp-ad-vertical.webm \
--screenshot /tmp/wowngeon-pvp-ad-vertical.png
```
The same seed supports the eight-player maximum for a crowded 16:9 arena take:
```bash
NODE_ENV=development npm run capture:pvp -- \
--confirm-local-free-only \
--ruleset race \
--players 8 \
--focus bot-4 \
--viewport 1920x1080 \
--output /tmp/wowngeon-pvp-ad-8player.webm \
--screenshot /tmp/wowngeon-pvp-ad-8player.png \
--trace /tmp/wowngeon-pvp-ad-8player.json
```
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
The default seed is fixed. A custom seed must be exactly 64 hexadecimal characters:
```bash
NODE_ENV=development npm run capture:pvp -- \
--confirm-local-free-only \
--seed beb270f3806a97e9ef73c8f83a6eae19a92f90ab38af9ad8b365cb74c41b2702
```
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 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.
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.
+157
View File
@@ -0,0 +1,157 @@
# Immutable release artifacts
`scripts/deploy/build-release-artifact.sh` is the local, non-activating boundary between a reviewed
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.
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 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 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>
sha256sum --check <release-id>.tar.gz.sha256
tar -xzf <release-id>.tar.gz -C <private-test-directory>
cd <private-test-directory>/<release-id>
sha256sum --check .release-files.sha256
cd src
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 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
Use the same runtime artifact SHA-256 for `monerogue` and `wownerogue`. Transfer it into a
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 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 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 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. 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 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, 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.
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
operator-reviewed recovery decision.
+151
View File
@@ -0,0 +1,151 @@
# Render kit: modes, packs and FX
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 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.
Registered packs (`assetPacks.js`):
| 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 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 publishes only the browser build and the two required addons:
```text
/vendor/three/0.160.0/three.module.min.js
/vendor/three/0.160.0/addons/loaders/GLTFLoader.js
/vendor/three/0.160.0/addons/utils/SkeletonUtils.js
```
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.
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 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` 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`, `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 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.
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.
## 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 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
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 constraints that are easy to get wrong:
- 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.
- `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. 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.
## Module reference
| File | Role |
|------|------|
| `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/renderScene.test.js`, `test/renderPackResolver.test.js`, `test/packRegistry_ultra.test.js`,
`test/rendererCdnPolicy.test.js`, `test/pvpUiPolish.test.js`.
+195
View File
@@ -0,0 +1,195 @@
# Rulesets
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 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.
| 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) | `{}` | |
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` 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?
rank(room) final ordering (best-first) for placement; competitive modes also set winnerId
```
- **`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.
`resolveWinCondition(type)` returns `FIRST_TO_EXIT` for any unknown type.
## Engine integration
`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:
- 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`).
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` 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).
## World presets and per-chain tuning
`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 / 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
```
+348
View File
@@ -0,0 +1,348 @@
# XMR stagenet financial canary
`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. 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.
## Safety envelope
The harness fails closed unless all of these hold.
- `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
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, 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.
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 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 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, 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.
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
MONSTER_SPEED=0
DUNGEON_LEVELS=1
```
`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.
### Scenario database names
Both the harness and the application validate the database name, with slightly different rules,
and the name must satisfy both.
- 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 # direct-2x
monerogue_canary_direct_treasure_e2e # direct-3x
```
### Boot order
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
PORT=3102
CANARY_DATABASE_HANDSHAKE=I_AM_AN_ISOLATED_XMR_STAGENET_CANARY
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
`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 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
E2E_FUNDING_RPC_PASSWORD_FILE=/run/credentials/canary/funding-rpc-password
E2E_DATABASE_URL_FILE=/run/credentials/canary/database-url
E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce
```
`E2E_FOO` and `E2E_FOO_FILE` are mutually exclusive for each secret.
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:
```bash
E2E_MODE=preflight \
E2E_TARGET=http://127.0.0.1:3102 \
npm --prefix src run canary:stagenet
```
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 \
E2E_SCENARIO=direct-2x \
E2E_EXPECT_DATABASE=monerogue_canary_direct_e2e \
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
own protected connection file.
## Live direct 2× escape
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 \
E2E_SCENARIO=direct-2x \
E2E_TARGET=http://127.0.0.1:3102 \
E2E_EXPECT_DATABASE=monerogue_canary_direct_e2e \
E2E_DATABASE_URL_FILE=/run/credentials/canary/direct-database-url \
E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce \
E2E_FUNDING_RPC_URL=http://127.0.0.1:38085 \
E2E_FUNDING_RPC_USER_FILE=/run/credentials/canary/funding-rpc-user \
E2E_FUNDING_RPC_PASSWORD_FILE=/run/credentials/canary/funding-rpc-password \
E2E_MAX_TRANSFER_ATOMIC=REPLACE_WITH_REVIEWED_ATOMIC_CEILING \
E2E_FEE_CUSHION_ATOMIC=1000000000 \
E2E_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_ONE_XMR_STAGENET_TRANSFER \
E2E_SCENARIO_CONFIRM=DIRECT_2X_ESCAPE \
E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL \
npm --prefix src run canary:stagenet
```
## Live direct 3× treasure escape
```bash
E2E_MODE=live-stagenet \
E2E_SCENARIO=direct-3x \
E2E_TARGET=http://127.0.0.1:3102 \
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 \
E2E_FUNDING_RPC_USER_FILE=/run/credentials/canary/funding-rpc-user \
E2E_FUNDING_RPC_PASSWORD_FILE=/run/credentials/canary/funding-rpc-password \
E2E_MAX_TRANSFER_ATOMIC=REPLACE_WITH_REVIEWED_ATOMIC_CEILING \
E2E_FEE_CUSHION_ATOMIC=1000000000 \
E2E_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_ONE_XMR_STAGENET_TRANSFER \
E2E_SCENARIO_CONFIRM=DIRECT_3X_TREASURE_ESCAPE \
E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL \
npm --prefix src run canary:stagenet
```
## 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
A passing run leaves the scenario database with exactly:
- 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.
## 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
The harness prints `one exact, non-retriable XMR stagenet transfer was broadcast` immediately after
its sole `transfer` call.
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.
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.
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
npm --prefix src test -- --runTestsByPath ../test/canaryDatabaseIdentity.test.js ../test/stagenetFinancialCanary.test.js
npm --prefix src run canary:stagenet -- --help
```
These parse the harness, run its pure and static safety tests, and print help. They contact no
application, database, or wallet.
+338
View File
@@ -0,0 +1,338 @@
# Tavern and Multiplayer
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.
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 surfaces sit on a real-time, multi-occupant `Room`:
- **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 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.
`src/multiplayer/`:
- `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 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
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 selections are normalized and
enforced by server entitlement policy regardless of what the client sends.
## Match
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.
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.
## Operator policy
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 |
|---|---|
| `free` | No entry cost or payout; competitive results use the Free/Pleb board. |
| `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. |
`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.
## Character appearance contract
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.
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 contract is 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.
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.
## Entitlements and the cosmetic catalog
`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.
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 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 (see `src/.env.example`). A grant payload normalizes to
`{ credits, packs: [{ id, expiresAt, source }], premiumLevel }`, and unknown pack ids are dropped.
```json
{
"id": "supporter_25",
"label": "25 Credit Supporter Pack",
"credits": 20,
"bonus": 5,
"price": 1000000000000,
"grants": {
"packs": ["generated-skins", "kenney-3d-characters"],
"premiumLevel": "supporter"
}
}
```
```json
{
"id": "pack_3d",
"label": "3D Character Pack",
"price": 250000000000,
"grants": {
"packs": ["kenney-3d-characters"]
}
}
```
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` 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 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 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`
never fires.
## Chain ambience
The Tavern stage carries a decorative chain layer (`RK.mountChainAmbience`, `chainAmbience.js`):
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 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.
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
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.
Selectable render techniques (`RK.RENDER_MODES`):
| 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 |
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.
`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.
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.
## Asset delivery and performance
- **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.
## Client and transport
`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`.
`src/network/tavernMatchBridge.js` relays match progress into the Tavern (`tavern_match_tick`,
`tavern_match_end`) and requires both `TAVERN_ENABLED` and `MATCH_ENABLED`.
`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.
`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`.
## Tests
- `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.
## Running locally
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.
+1183
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 968 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 847 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+8
View File
@@ -0,0 +1,8 @@
Art assets in this folder are by Kenney (https://kenney.nl), released under
Creative Commons Zero (CC0 1.0 Universal) — public domain.
- roguelikeIndoor.png — Kenney "Roguelike Interior Pack"
- roguelikeChar.png — Kenney "Roguelike Characters Pack"
Tile grid: 16x16 px, 1 px spacing (17 px stride), no margin.
No attribution is legally required; Kenney is credited here with thanks.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Some files were not shown because too many files have changed in this diff Show More