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.
@@ -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
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 968 B |
|
After Width: | Height: | Size: 847 B |
|
After Width: | Height: | Size: 963 B |
|
After Width: | Height: | Size: 976 B |
|
After Width: | Height: | Size: 1022 B |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.1 KiB |